diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 19276a4..c215a26 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -15,6 +15,211 @@ ## Entries (newest first) +## 2026-06-22 — extract_classes.py transcoded to Rust byte-faithfully; XSD↔TTL bijection closed; Python dependency removed from the oracle +**Status:** FINDING +**Scope:** XSD front-end × calibration self-containment × the queued bijection + +The MARS XSD classification extractor (`arago/MARS-Schema/tools/extract_classes.py`, +~360 lines, ~140 logic + ~150 table formatting) is now a faithful Rust +transcode at `crates/ogar-from-schema/src/xsd.rs`, behind the optional +`xsd` feature (pulls `roxmltree`, a pure-Rust read-only XML DOM; the +default TTL path stays zero-parser-deps). + +Three things this lands: + +1. **Byte-for-byte transcode proof.** `xsd::to_asciidoc()` reproduces + the Python `-F asciidoc` output exactly — 628 lines, including the + verbatim XSD-documentation whitespace and the `printAsciiDocFooter` + trailing newline. Test: `xsd::tests::asciidoc_matches_python_oracle` + diffs against the cached `_oracle/classifications.adoc`. + +2. **The XSD↔TTL bijection is closed (was "queued" in + `MARS-TRANSCODING.md §2`).** `xsd::tests::xsd_classes_match_ttl_enum` + asserts FULL bidirectional set-equality between the XSD-extracted + Application value set and the TTL `validation-parameter` enum — not + just one-directional membership. The XSD and the TTL are two + independent encodings of one taxonomy and they now provably agree + in both directions. + +3. **The Python dependency is removed from the calibration path.** + `cargo test --features xsd` is the whole oracle now; no `python3` + interpreter needed. `extract_classes.py` stays vendored in + `_oracle/` as the provenance witness (what the transcode was proven + against), not a runtime dep. + +Transcode discipline notes (for the next source→Rust port): +- The Python `getAttribute("xml:lang")` returns `""` for absent (not + `None`); the lang-filter is "absent OR en". roxmltree resolves `xml:` + to the xml namespace — match on `attribute.name() == "lang"`. +- `getXMLText` concatenates DIRECT text-node children only (not + recursive); the documentation's internal whitespace is load-bearing + for the byte-match. +- The `:revdate:` is `datetime.now()` in Python (non-deterministic); + the Rust `to_asciidoc(c, revdate)` takes it as a parameter so the + output is reproducible and testable. + +Answer to "is it huge": no — ~360 lines, half output formatting; the +transcode is ~350 LOC Rust including tests. And it doubles as the seed +of the broader XSD→`Class` front-end (the same walk that extracts +classifications is the structural-arm lift for any XSD). + +Evidence: `crates/ogar-from-schema/src/xsd.rs` (20/20 tests pass with +`--features xsd`; 16/16 on default). `docs/MARS-TRANSCODING.md §2` +updated to mark the bijection closed. + +## 2026-06-22 — OGIT is the canonical template store; Odoo (and any source-AST producer) digests INTO it; consumers relive agnostically via askama +**Status:** FRAMING +**Scope:** Foundry-parity collapse × cross-consumer architecture × digest-once-relive-N + +The operator's framing — *"basically digest Odoo and store it in TTL +'Jinja' Templates in OGIT and relive it agnostically for any +'verb/entity as a class'"* — crystallizes the four pieces shipped +across this PR (TTL mirror, schema lift, verb-as-class template, +author-provenance discriminator) into one coherent flow: + +``` +Odoo source → ogar-from-python → Class IR → ttl_emit → OGIT TTL templates + (stored at + vocab/imports/ogit/NTO//, + dcterms:creator = bus-compiler) + │ + ▼ + ogar-render-askama + │ + ▼ + any consumer (woa-rs, smb-office-rs, medcare-rs, q2, future renderers) + re-instantiates any entity/verb-as-class with a fresh binding; + never touches Odoo Python +``` + +The Python runtime is **only** touched at digest time. After that, +every consumer talks to TTL templates plus the askama engine. + +**Why store in OGIT NTO (not a parallel `vocab/imports/odoo/`):** the +`dcterms:creator` author-scan finding from this same session makes +provenance unambiguous without a separate namespace. The precedent +exists today — `Accounting/` already has 11 Claude-digested files +sitting alongside 23 Viktor Voss originals. + +**Foundry-parity collapse** (the punchline). Foundry's four-layer +platform pitch (ingest / storage / render / IAM+audit) maps to four +free open-source pieces already in this repo: + +| Foundry layer | Our equivalent | New code needed | +|---|---|---| +| Ingest | `ogar-from-python` digest | ~1500 LOC (queued) | +| Storage | `vocab/imports/ogit/NTO//` TTL with `dcterms:creator` | zero (exists) | +| Render | `ogar-render-askama::{views, actions}` | ~200 LOC for actions submodule | +| IAM + audit | verb-as-class `requires-perm` slot + Lance-version-as-audit | zero (exists) | +| Ontology change mgmt | `diff -r` of digest re-runs | zero | + +Total marginal code: <2000 LOC for what Foundry charges $$$ for. The +architecture was latent the whole time; the digest→relive framing is +what makes it visible as a single shape. + +Concrete next steps (independent, can ship in parallel PRs): + +1. `ogar-from-python` digester — structural-arm filter (`_name`, + `_inherit`, `fields.*`, selections); behavioural-arm signatures + (decorator names + action method signatures); drops method bodies +2. `ogar-render-askama::actions` — verb-as-class render path, mirroring + the existing `views/` submodule + +Doc: `docs/ODOO-DIGEST-TO-OGIT.md` (FRAMING v0) carries the full +pipeline, the v0 mapping table (6 minted concepts + ~9 queued for +codebook mint), the drift detector recipe, and the Foundry-parity +collapse table. + +## 2026-06-22 — Verb-as-class is an ontological askama template — compile-time-validated action declaration, not a quirk +**Status:** FINDING +**Scope:** WorkOrder convention × `ogar-render-askama` integration × Foundry action-type parity + +WorkOrder's 12 `verbs/*.ttl` are declared as `rdfs:Class`, not +`owl:ObjectProperty`. The earlier framing (commit `cce8420`) called +this "an unusual convention we're free to revise toward standard +`owl:ObjectProperty`" — **that framing was wrong** and is hereby +corrected. + +The verb-as-class encoding is **load-bearing**: it makes each verb a +typed template carrying its own slot list (`ogit:mandatory-attributes`), +inheritance chain (`rdfs:subClassOf`), and policy metadata +(`ogit:requires-perm`, `ogit:emits-audit`). That's not a flat predicate; +that's a **compile-time-validated action declaration** — the ontological +counterpart to askama (Rust) and jinja (Python) HTML templating. + +The structural correspondence is exact: + +- TTL file = template (`.html.j2` equivalent) +- `ogit:mandatory-attributes` = struct field list (askama context shape) +- Per-call binding = struct instance (askama render input) +- Render = SPO triple emit + declared side effects (audit, ACL gate) +- `rdfs:subClassOf` = template inheritance (`{% extends %}`) +- Lift-time slot validation = askama's compile-time `{{ field }}` check + +This is the integration point `ogar-render-askama` was always going +to need for actions. The crate currently renders `Class` *views* +(noun-shaped: HTML/JSON/OpenAPI); a parallel `actions/` submodule +renders `Class` *actions* (verb-shaped: SPO triple + side-effect spec). +Same engine, same compile-time-validated context model, different +output medium. + +**Foundry-parity sharpening:** Foundry's "action types" carry exactly +the four properties this encoding gives — typed parameters, slot +validation, declared side effects, inheritance. Foundry sells it as a +paid platform feature; verb-as-class TTL + `ogar-render-askama` gives +the same four from open-source schemas and Rust templates. + +Implications: +- **WorkOrder's convention stays.** Don't normalise to `owl:ObjectProperty`. +- **WorkOrder is the natural prototyping ground** (we're upstream per + `dcterms:creator` = `bus-compiler` + `family-codec-smith`) for new + verb-as-class predicates before pitching the pattern to OGIT upstream. +- **`ogar-render-askama::actions` is the next natural module** — + ~200 LOC mirroring the existing `views/` render path. + +Doc: `docs/VERB-AS-CLASS-TEMPLATE.md` (FRAMING v0) carries the full +analogy table + worked example + render flow. + +## 2026-06-22 — Author provenance via `dcterms:creator` discriminates "ours to revise" from "upstream-coordinated" +**Status:** FINDING +**Scope:** OGIT NTO governance × multi-domain lift × who-can-change-what + +OGIT TTL files carry `dcterms:creator` on every subject. The field is +free-form text but carries one of two semantic shapes in practice: + +- **Human author + email** (`chris.boos@almato.com`, `Viktor Voss`, + `fotto@arago.de`, `Marek Meyer`, `Peter Larem`, `Ola Irgens Kylling`, + …) — original arago/almato authors. Structural changes need upstream + coordination. +- **Internal agent name** (`bus-compiler`, `family-codec-smith`, + `Claude (AdaWorldAPI/lance-graph 3-hop optim)`, …) — files authored + by our agent fleet against this org's forks. We are upstream for + these; structural changes need no external coordination. + +The 9-domain spot check (Transport, Accounting, SalesDistribution, +Credit, Cost, ServiceManagement, WorkOrder, Compliance, Audit) revealed: + +- **WorkOrder is fully ours** — 100% internal-agent authorship (`bus-compiler`, + `family-codec-smith`). The unusual `rdfs:Class`-as-verb convention is + ours to revise toward standard `owl:ObjectProperty`-as-verb whenever + the AST predicate registry needs the WorkOrder verbs. +- **Accounting is mixed-authorship** — Viktor Voss (23 files, original) + + a prior session's `Claude` extension (11 files). Structural changes + to the original 23 require upstream coordination; the 11 are ours. +- **All other 7 domains are pure-upstream** — single-or-few external + human authors. + +This makes WorkOrder the **natural prototyping ground** for new TTL +predicates OGAR wants to add: ship in WorkOrder first (no external +coordination cost), validate the bijection, then pitch the pattern +to OGIT upstream once it's proven. + +Evidence: the `dcterms:creator` provenance scan recipe lives in +`docs/OGIT-DOMAIN-LIFT-CATALOGUE.md § Verifying domain authorship`; +the round-trip stress test for the 9 domains is +`ttl_emit::tests::nine_domains_lift_surface_round_trip` (zero failures +on 210 TTLs across the nine). + ## 2026-06-22 — Schema-vs-source duality: schemas lift structure bijectively; source ASTs lift behaviour best-effort; they cross-validate at the structural boundary **Status:** FINDING **Scope:** producer architecture × MARS calibration × Foundry-Odoo lens × the bardioc migration diff --git a/crates/ogar-from-schema/Cargo.toml b/crates/ogar-from-schema/Cargo.toml index a717832..efafbce 100644 --- a/crates/ogar-from-schema/Cargo.toml +++ b/crates/ogar-from-schema/Cargo.toml @@ -11,14 +11,21 @@ description = "Schema-as-input producer family for OGAR IR. Lifts the STRUCTURAL [features] default = [] serde = ["dep:serde", "ogar-vocab/serde"] +# The `xsd` feature pulls in a read-only XML parser for the XSD +# front-end (the faithful Rust transcode of arago/MARS-Schema's +# extract_classes.py). Kept optional so the default TTL path stays +# zero-parser-deps. +xsd = ["dep:roxmltree"] [dependencies] ogar-vocab = { path = "../ogar-vocab" } serde = { workspace = true, optional = true } +roxmltree = { version = "0.20", optional = true } -# No external TTL/XSD parser pulled in yet: the v0 reader uses a -# narrow line-oriented walker that handles the shapes OGIT's TTL -# actually emits (a tiny, machine-stable subset of full Turtle). -# When the surface grows (Wikidata-shaped TTL, full RDF/XML, OWL -# imports), swap in `oxttl` / `oxrdf` here without touching the +# The default TTL reader uses a narrow line-oriented walker that +# handles the shapes OGIT's TTL actually emits (a tiny, machine-stable +# subset of full Turtle) — no external parser. The `xsd` feature adds +# roxmltree (pure-Rust, read-only DOM) ONLY for the XSD front-end. +# When the TTL surface grows (Wikidata-shaped TTL, full RDF/XML, OWL +# imports), swap in `oxttl` / `oxrdf` without touching the # Class/Attribute target shape this crate produces. diff --git a/crates/ogar-from-schema/src/lib.rs b/crates/ogar-from-schema/src/lib.rs index 67c4e9a..c5a1a8c 100644 --- a/crates/ogar-from-schema/src/lib.rs +++ b/crates/ogar-from-schema/src/lib.rs @@ -60,6 +60,8 @@ use ogar_vocab::{Attribute, Class, EnumDecl, EnumSource, Language}; pub mod sgo; pub mod ttl; pub mod ttl_emit; +#[cfg(feature = "xsd")] +pub mod xsd; /// What a single TTL file describes — exactly one of: an entity (`Class`), /// a datatype attribute (`Attribute`), or a verb (`Association` shape). diff --git a/crates/ogar-from-schema/src/ttl_emit.rs b/crates/ogar-from-schema/src/ttl_emit.rs index 9fc1da7..3960362 100644 --- a/crates/ogar-from-schema/src/ttl_emit.rs +++ b/crates/ogar-from-schema/src/ttl_emit.rs @@ -276,20 +276,102 @@ mod tests { /// predicate. #[test] fn all_mars_ttl_files_roundtrip() { + let stats = assert_domain_roundtrip("MARS"); + // 29 .ttl files in NTO/MARS at the SHA pinned by PROVENANCE.md. + assert!( + stats.total >= 29, + "expected ≥ 29 TTL files in MARS, got {}", + stats.total + ); + } + + /// Generic helper that walks `vocab/imports/ogit/NTO//`, + /// dispatches each TTL to the right parser (`parse_file` for entities + /// and datatype attributes, `crate::sgo::parse_verb` for in-domain + /// `owl:ObjectProperty` verbs), and asserts semantic round-trip. + /// Returns per-shape counts so callers can sanity-check the lift + /// surface they're claiming. + fn assert_domain_roundtrip(domain: &str) -> DomainStats { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../vocab/imports/ogit/NTO/MARS"); - let mut checked = 0usize; + .join("../../vocab/imports/ogit/NTO") + .join(domain); + let mut stats = DomainStats::default(); for entry in walk_ttl(&dir) { let src = std::fs::read_to_string(&entry).expect("read"); + stats.total += 1; match parse_file(&src) { - Some(TtlDeclaration::Entity(_)) => assert_entity_roundtrip(&src), - Some(TtlDeclaration::DatatypeAttribute(_)) => assert_attribute_roundtrip(&src), - None => {} + Some(TtlDeclaration::Entity(_)) => { + assert_entity_roundtrip(&src); + stats.entities += 1; + } + Some(TtlDeclaration::DatatypeAttribute(_)) => { + assert_attribute_roundtrip(&src); + stats.attributes += 1; + } + None => { + // Try the verb path — some NTO domains carry their own + // in-domain `owl:ObjectProperty` verbs (Transport, + // Accounting, Credit, Compliance) alongside SGO's + // upstream-shared vocabulary. + let Some(once) = crate::sgo::parse_verb(&src) else { + panic!( + "TTL has no recognised subject type in {domain}: {}", + entry.display() + ); + }; + let emitted = crate::sgo::emit_verb(&once); + let twice = crate::sgo::parse_verb(&emitted).expect("re-parse verb"); + assert_eq!( + once, + twice, + "verb round-trip lost a predicate in {domain}: {}", + entry.display() + ); + stats.verbs += 1; + } } - checked += 1; } - // 29 .ttl files in NTO/MARS at the SHA pinned by PROVENANCE.md. - assert!(checked >= 29, "expected ≥ 29 TTL files, got {checked}"); + stats + } + + #[derive(Debug, Default)] + struct DomainStats { + total: usize, + entities: usize, + attributes: usize, + verbs: usize, + } + + /// Cross-domain bijection coverage. Each row is one of the nine + /// domains the operator asked OGAR to verify before promoting + /// the lift surface from MARS-only to multi-domain. If any of + /// these fails, the producer can't land on that domain without + /// extending `EntityDecl` / `AttributeDecl` / `VerbDecl` first. + /// + /// Counts are also a sanity check on the inventory — they prove + /// the catalogue's per-domain numbers match what's actually in + /// `vocab/imports/`. + #[test] + fn nine_domains_lift_surface_round_trip() { + for (domain, expected_total) in [ + ("Transport", 27), + ("Accounting", 36), + ("SalesDistribution", 23), + ("Credit", 21), + ("Cost", 5), + ("ServiceManagement", 59), + ("WorkOrder", 27), + ("Compliance", 9), + ("Audit", 3), + ] { + let stats = assert_domain_roundtrip(domain); + assert_eq!( + stats.total, expected_total, + "{domain}: TTL count drifted from inventory \ + (expected {expected_total}, got {})", + stats.total + ); + } } fn walk_ttl(root: &std::path::Path) -> Vec { diff --git a/crates/ogar-from-schema/src/xsd.rs b/crates/ogar-from-schema/src/xsd.rs new file mode 100644 index 0000000..53bbb1e --- /dev/null +++ b/crates/ogar-from-schema/src/xsd.rs @@ -0,0 +1,471 @@ +//! XSD front-end — a faithful Rust transcode of `arago/MARS-Schema`'s +//! `tools/extract_classes.py`. +//! +//! The Python script (Python 2, ~360 lines, ~140 of which are the +//! extraction logic and ~150 the table formatters) walks a MARS XSD and +//! enumerates the node-classification taxonomy: for the four master +//! node types (Application / Resource / Software / Machine) it pulls +//! every `(Class, SubClass)` pair out of the `xs:extension` / fixed- +//! `xs:attribute` chain, attaches the English `xs:documentation`, and +//! renders an asciidoc or HTML table. +//! +//! This module reproduces that walk and both output formats **byte for +//! byte** — the transcode proof is `tests::asciidoc_matches_python_oracle`, +//! which asserts the Rust output equals the cached Python output at +//! `_oracle/classifications.adoc`. +//! +//! # Why transcode it +//! +//! 1. **Removes the Python dependency from the calibration oracle.** The +//! MARS bijection (`docs/MARS-TRANSCODING.md`) no longer needs a +//! `python3` interpreter — `cargo test` is the whole proof. +//! 2. **Seeds the broader XSD → `Class` front-end.** The same walk that +//! extracts classifications is the structural-arm lift for any XSD +//! schema; [`classifications`] is the first consumer, a future +//! `into_classes` the second. +//! 3. **Closes the XSD ↔ TTL bijection.** The classification set this +//! module extracts must equal the `ogit:validation-parameter` enum +//! set the TTL front-end lifts — see +//! `tests::xsd_classes_match_ttl_enum`. +//! +//! Feature-gated behind `xsd` so the default TTL path stays +//! zero-parser-deps. + +use std::collections::BTreeMap; + +use roxmltree::{Document, Node}; + +/// The four MARS master complex types and the node type each anchors. +/// Mirrors the Python `master_types` dict. +const MASTER_TYPES: [(&str, &str); 4] = [ + ("MachineAttributes", "Machine"), + ("ResourceAttributes", "Resource"), + ("SoftwareAttributes", "Software"), + ("ApplicationAttributes", "Application"), +]; + +fn master_type(base: &str) -> Option<&'static str> { + MASTER_TYPES + .iter() + .find(|(b, _)| *b == base) + .map(|(_, t)| *t) +} + +/// A lifted classification record — the Rust counterpart of the Python +/// `parsed_data` dict. `fixed` maps a fixed-attribute *type* name +/// (`"ApplicationClass"`, `"ApplicationSubClass"`, `"MachineClass"`, …) +/// to its fixed *value*, exactly as the script does with +/// `parsed_data[parsed_data["_fixed"]["type"]] = value`. +#[derive(Debug, Clone, Default)] +struct Record { + name: String, + base: String, + node_type: Option, + fixed: BTreeMap, + doc: Vec, +} + +/// The full extraction result: per-node-type element records, ready for +/// table rendering. Keyed by node type (`"Application"` … `"Machine"`). +#[derive(Debug, Default)] +pub struct Classifications { + /// MARS schema version string (the `version` attribute on + /// `xs:schema`, e.g. `"5.3.8"`). + pub version: String, + /// Element records grouped by node type. + elements: BTreeMap>, +} + +impl Classifications { + /// Every `(node_type, class, subclass)` triple extracted, sorted. + /// `subclass` is `None` for the 2-column node types (Resource, + /// Machine). This is the structured surface the TTL cross-check + /// reads. + #[must_use] + pub fn triples(&self) -> Vec<(String, String, Option)> { + let mut out = Vec::new(); + for (node_type, recs) in &self.elements { + let class_key = format!("{node_type}Class"); + let sub_key = format!("{node_type}SubClass"); + for r in recs { + if let Some(class) = r.fixed.get(&class_key) { + out.push(( + node_type.clone(), + class.clone(), + r.fixed.get(&sub_key).cloned(), + )); + } + } + } + out.sort(); + out.dedup(); + out + } + + /// All distinct class+subclass values for a node type — the set + /// that must equal the TTL `ogit:validation-parameter` enum for + /// the same `/attributes/{class,subClass}.ttl` files. + #[must_use] + pub fn value_set(&self, node_type: &str) -> Vec { + let mut set = std::collections::BTreeSet::new(); + let class_key = format!("{node_type}Class"); + let sub_key = format!("{node_type}SubClass"); + if let Some(recs) = self.elements.get(node_type) { + for r in recs { + if let Some(v) = r.fixed.get(&class_key) { + set.insert(v.clone()); + } + if let Some(v) = r.fixed.get(&sub_key) { + set.insert(v.clone()); + } + } + } + set.into_iter().collect() + } +} + +/// Parse a MARS XSD into [`Classifications`]. Faithful transcode of the +/// Python `extract_from_xml`. +/// +/// # Errors +/// +/// Returns `Err` if the XML fails to parse. +pub fn classifications(xsd: &str) -> Result { + let doc = Document::parse(xsd)?; + let root = doc.root_element(); // the xs:schema element + let version = root.attribute("version").unwrap_or_default().to_owned(); + + let mut el_data: Vec = Vec::new(); + let mut ct_data: BTreeMap = BTreeMap::new(); + + for child in root.children().filter(Node::is_element) { + match child.tag_name().name() { + "element" => { + if let Some(rec) = parse_subject(child, /* is_element */ true) { + el_data.push(rec); + } + } + "complexType" => { + // Only complex types whose base IS a master type get a + // node_type (and are therefore stored) — mirrors the + // Python `if parsed_data.has_key("TYPE")` gate. + if let Some(rec) = parse_subject(child, /* is_element */ false) + && rec.node_type.is_some() + { + ct_data.insert(rec.name.clone(), rec); + } + } + _ => {} + } + } + + // Post-process phase II: an element whose own base is an + // intermediate complex type (not a master type) inherits that + // type's node_type + class-level fixed value. + for el in &mut el_data { + if el.node_type.is_none() + && let Some(base_rec) = ct_data.get(&el.base) + { + el.node_type = base_rec.node_type.clone(); + for (k, v) in &base_rec.fixed { + el.fixed.insert(k.clone(), v.clone()); + } + } + } + + let mut elements: BTreeMap> = BTreeMap::new(); + for el in el_data { + if let Some(nt) = el.node_type.clone() { + elements.entry(nt).or_default().push(el); + } + } + + Ok(Classifications { version, elements }) +} + +/// Shared parser for both `xs:element` and `xs:complexType` subjects — +/// the Python `parse_element_data` / `parse_complex_type` are nearly +/// identical; the only difference is that a complex type sets its +/// node_type *only* when its base is a master type, whereas an element +/// also records its fixed value unconditionally. +fn parse_subject(node: Node, is_element: bool) -> Option { + // First descendant xs:extension. + let ext = node + .descendants() + .find(|n| n.is_element() && n.tag_name().name() == "extension")?; + let base = strip_aae(ext.attribute("base").unwrap_or_default()); + + // Last direct xs:attribute child wins (mirrors the Python loop that + // overwrites `_fixed` each iteration). + let mut fixed_name = None; + let mut fixed_type = None; + let mut fixed_value = None; + for attr_el in ext.children().filter(Node::is_element) { + if attr_el.tag_name().name() == "attribute" { + fixed_name = Some(attr_el.attribute("name").unwrap_or_default().to_owned()); + fixed_type = Some(strip_aae(attr_el.attribute("type").unwrap_or_default())); + fixed_value = Some(attr_el.attribute("fixed").unwrap_or_default().to_owned()); + } + } + let (_fixed_name, fixed_type, fixed_value) = (fixed_name?, fixed_type?, fixed_value?); + + // English documentation: every descendant xs:documentation whose + // xml:lang is absent or "en". + let mut docs = Vec::new(); + for doc_el in node + .descendants() + .filter(|n| n.is_element() && n.tag_name().name() == "documentation") + { + let lang = doc_el + .attributes() + .find(|a| a.name() == "lang") + .map(|a| a.value()); + if lang.is_none() || lang == Some("en") { + docs.push(text_of(doc_el)); + } + } + + let mut rec = Record { + name: node.attribute("name").unwrap_or_default().to_owned(), + base: base.clone(), + node_type: None, + fixed: BTreeMap::new(), + doc: docs, + }; + + let is_master = master_type(&base); + if let Some(t) = is_master { + rec.node_type = Some(t.to_owned()); + rec.fixed.insert(fixed_type.clone(), fixed_value.clone()); + } + if is_element && is_master.is_none() { + // Element extending an intermediate type: still records its own + // fixed value (the SubClass) even without a node_type yet. + rec.fixed.insert(fixed_type, fixed_value); + } + + Some(rec) +} + +/// Concatenate the direct text-node children of an element, verbatim — +/// the Python `getXMLText`. Preserves the XSD documentation's internal +/// whitespace exactly (load-bearing for the byte-match). +fn text_of(node: Node) -> String { + node.children() + .filter(Node::is_text) + .filter_map(|c| c.text()) + .collect() +} + +fn strip_aae(s: &str) -> String { + s.strip_prefix("aae:").unwrap_or(s).to_owned() +} + +// ───────────────────────────────────────── output formatters ── + +/// Render the asciidoc table set, byte-for-byte equal to the Python +/// `-F asciidoc` output. `revdate` is the `:revdate:` value (the Python +/// script uses `datetime.now()`, non-deterministic; here it is a +/// parameter so the output is reproducible and testable). +#[must_use] +pub fn to_asciidoc(c: &Classifications, revdate: &str) -> String { + let mut out = String::new(); + // printAsciiDocTitle + printAsciiDocHeader + out.push_str(&format!( + "= Node classifications from MARS Schema {}\n", + c.version + )); + out.push_str(":toc:\n"); + out.push_str(&format!(":revdate: {revdate}\n")); + out.push_str("\n<<<\n\n"); + out.push_str( + "[NOTE]\n====\nThe MARS Schema uses a different versioning cycle from HIRO Product. \ + It is expected to see the two versions deviate from each other.\n\nThis list was \ + last updated on: *{revdate}*\n====\n\n\n", + ); + adoc_table3(&mut out, c, "Application"); + adoc_table2(&mut out, c, "Resource"); + adoc_table3(&mut out, c, "Software"); + adoc_table2(&mut out, c, "Machine"); + // printAsciiDocFooter — a single trailing `print ""`. + out.push('\n'); + out +} + +fn adoc_table3(out: &mut String, c: &Classifications, node_type: &str) { + let class_key = format!("{node_type}Class"); + let sub_key = format!("{node_type}SubClass"); + out.push_str(&format!("== {node_type} Node Classifications\n\n")); + out.push_str("[cols=\"1,1,3\", options=\"header\"]\n"); + out.push_str("|===\n"); + out.push_str(&format!("|{class_key}|{sub_key}|Description\n")); + let (by_class, by_name) = group_3col(c, node_type, &class_key, &sub_key); + for (cl, subs) in &by_class { + for sub in subs { + if let Some(doc) = by_name.get(sub) { + out.push_str(&format!("|{cl}|{sub}|{}\n", doc.join("
"))); + } + } + } + out.push_str("|===\n\n"); +} + +fn adoc_table2(out: &mut String, c: &Classifications, node_type: &str) { + let class_key = format!("{node_type}Class"); + out.push_str(&format!("== {node_type} Node Classifications\n\n")); + out.push_str("[cols=\"1,5\", options=\"header\"]\n"); + out.push_str("|===\n"); + out.push_str(&format!("|{class_key}|Description\n")); + let by_name = group_2col(c, node_type, &class_key); + for (cl, doc) in &by_name { + out.push_str(&format!("|{cl}|{}\n", doc.join("
"))); + } + out.push_str("|===\n\n"); +} + +/// Build the sorted class→subclasses ordering + the subclass-name→doc +/// lookup for a 3-column node type. +fn group_3col( + c: &Classifications, + node_type: &str, + class_key: &str, + sub_key: &str, +) -> (BTreeMap>, BTreeMap>) { + let mut by_class: BTreeMap> = BTreeMap::new(); + let mut by_name: BTreeMap> = BTreeMap::new(); + if let Some(recs) = c.elements.get(node_type) { + for r in recs { + if let (Some(cl), Some(sub)) = (r.fixed.get(class_key), r.fixed.get(sub_key)) { + by_class.entry(cl.clone()).or_default().push(sub.clone()); + by_name.insert(r.name.clone(), r.doc.clone()); + } + } + } + for subs in by_class.values_mut() { + subs.sort(); + subs.dedup(); + } + (by_class, by_name) +} + +/// Build the sorted class-name→doc lookup for a 2-column node type. +fn group_2col( + c: &Classifications, + node_type: &str, + class_key: &str, +) -> BTreeMap> { + let mut by_name: BTreeMap> = BTreeMap::new(); + if let Some(recs) = c.elements.get(node_type) { + for r in recs { + if r.fixed.contains_key(class_key) { + by_name.insert(r.name.clone(), r.doc.clone()); + } + } + } + by_name +} + +// ───────────────────────────────────────────────── tests ── + +#[cfg(test)] +mod tests { + use super::*; + + const XSD: &str = + include_str!("../../../vocab/imports/ogit/NTO/MARS/_oracle/MARSSchema2015.xsd"); + const ORACLE_ADOC: &str = + include_str!("../../../vocab/imports/ogit/NTO/MARS/_oracle/classifications.adoc"); + + #[test] + fn parses_version() { + let c = classifications(XSD).expect("parse"); + assert_eq!(c.version, "5.3.8"); + } + + #[test] + fn extracts_expected_counts() { + let c = classifications(XSD).expect("parse"); + // From PROVENANCE.md / the Python oracle: + // Application 7 classes × 50 subclass pairs + // Resource 19 classes + // Software 40 classes × 336 subclass pairs + // Machine 11 classes + let app: Vec<_> = c + .triples() + .into_iter() + .filter(|(n, ..)| n == "Application") + .collect(); + let mach: Vec<_> = c + .triples() + .into_iter() + .filter(|(n, ..)| n == "Machine") + .collect(); + let app_classes: std::collections::BTreeSet<_> = + app.iter().map(|(_, cl, _)| cl.clone()).collect(); + let mach_classes: std::collections::BTreeSet<_> = + mach.iter().map(|(_, cl, _)| cl.clone()).collect(); + assert_eq!(app_classes.len(), 7, "Application classes"); + assert_eq!(mach_classes.len(), 11, "Machine classes"); + } + + /// **The transcode proof.** The Rust asciidoc output must be + /// byte-for-byte equal to the cached Python `-F asciidoc` output. + /// The cached file was generated with `:revdate: 22-Jun-2026`, so + /// we pass the same value. + #[test] + fn asciidoc_matches_python_oracle() { + let c = classifications(XSD).expect("parse"); + let rust = to_asciidoc(&c, "22-Jun-2026"); + if rust != ORACLE_ADOC { + // Pinpoint the first divergence for a useful failure message. + let a: Vec<&str> = rust.lines().collect(); + let b: Vec<&str> = ORACLE_ADOC.lines().collect(); + for (i, (la, lb)) in a.iter().zip(b.iter()).enumerate() { + assert_eq!(la, lb, "first divergence at line {}", i + 1); + } + assert_eq!( + a.len(), + b.len(), + "line count differs (rust {} vs oracle {})", + a.len(), + b.len() + ); + } + } + + /// **Closes the XSD ↔ TTL bijection.** The XSD-extracted + /// Application class+subclass value set must equal what the TTL + /// front-end lifts from `Application/attributes/{class,subClass}.ttl`. + #[test] + fn xsd_classes_match_ttl_enum() { + use crate::TtlDeclaration; + use crate::ttl::parse_file; + + let c = classifications(XSD).expect("parse"); + let xsd_values: std::collections::BTreeSet = + c.value_set("Application").into_iter().collect(); + + const CLASS_TTL: &str = + include_str!("../../../vocab/imports/ogit/NTO/MARS/Application/attributes/class.ttl"); + const SUBCLASS_TTL: &str = include_str!( + "../../../vocab/imports/ogit/NTO/MARS/Application/attributes/subClass.ttl" + ); + let mut ttl_values = std::collections::BTreeSet::new(); + for ttl in [CLASS_TTL, SUBCLASS_TTL] { + let TtlDeclaration::DatatypeAttribute(a) = parse_file(ttl).expect("parse ttl") else { + panic!("expected datatype attribute"); + }; + for v in a.fixed_enum_values().expect("fixed enum") { + ttl_values.insert(v); + } + } + + // Full bidirectional equality — every TTL value in the XSD set + // AND every XSD value in the TTL set. This is the bijection the + // MARS-TRANSCODING.md §2 "queued" note asked for. + assert_eq!( + xsd_values, ttl_values, + "XSD-extracted Application value set differs from TTL enum set" + ); + } +} diff --git a/docs/MARS-TRANSCODING.md b/docs/MARS-TRANSCODING.md index bdfd7b1..f8dff45 100644 --- a/docs/MARS-TRANSCODING.md +++ b/docs/MARS-TRANSCODING.md @@ -92,15 +92,24 @@ classification taxonomy: | Direction | Oracle role | |---|---| -| XSD → classifications | `extract_classes.py -s MARSSchema2015.xsd -F asciidoc` enumerates every `(class, subclass)` pair | +| XSD → classifications (Python) | `extract_classes.py -s MARSSchema2015.xsd -F asciidoc` enumerates every `(class, subclass)` pair | +| XSD → classifications (Rust) | `ogar-from-schema::xsd::classifications()` — a **faithful transcode** of the Python script; `xsd::to_asciidoc()` reproduces the output **byte-for-byte** (`xsd::tests::asciidoc_matches_python_oracle`) | | TTL → classifications | OGIT `Application/attributes/class.ttl` etc. carry the same set in `ogit:validation-parameter` | | OGAR → classifications | `ogar-from-schema::ttl::AttributeDecl::fixed_enum_values()` lifts them as `EnumSource::Static` | -The three sets must be byte-equal modulo encoding artefacts (HTML/asciidoc -escape vs raw). The agreement test -(`ttl::tests::application_class_values_appear_in_xsd_oracle`) is the v0 -witness; expansion to full set-equality (XSD set == TTL set, no missing, -no extra) is queued. +**The bijection is now closed (was queued).** `xsd::tests::xsd_classes_match_ttl_enum` +asserts **full bidirectional set-equality** — every XSD-extracted +Application value is in the TTL enum AND every TTL enum value is in the +XSD set. The earlier one-directional membership test +(`ttl::tests::application_class_values_appear_in_xsd_oracle`) remains as +the lighter witness that runs without the `xsd` feature. + +**The Python dependency is gone.** Because `ogar-from-schema::xsd` is a +byte-faithful Rust transcode, the calibration no longer needs a +`python3` interpreter — `cargo test --features xsd` is the whole proof. +The Python `extract_classes.py` stays vendored in `_oracle/` as the +provenance witness (the thing the transcode was proven against), not as +a runtime dependency. Counts at MARS Schema 5.3.8: diff --git a/docs/ODOO-DIGEST-TO-OGIT.md b/docs/ODOO-DIGEST-TO-OGIT.md new file mode 100644 index 0000000..a76ee20 --- /dev/null +++ b/docs/ODOO-DIGEST-TO-OGIT.md @@ -0,0 +1,206 @@ +# Odoo digest → OGIT TTL templates → agnostic relive + +> **For colleagues building anything that needs Odoo as a typed ontology +> outside the Odoo Python runtime.** This is the architectural shape: +> digest Odoo source once into OGIT-shaped TTL templates (stored +> in-tree at `vocab/imports/ogit/NTO//`), then relive any model +> or workflow action agnostically via `ogar-render-askama`. +> +> Status: **FRAMING v0** (2026-06-22). Companion to +> `docs/ODOO-TRANSCODING.md` (the producer spec) and +> `docs/VERB-AS-CLASS-TEMPLATE.md` (the askama-template framing this +> reuses). + +The shape in one sentence: **`ogar-from-python` digests Odoo source +once into the OGAR IR; `ttl_emit` writes the IR back as OGIT-shaped +TTL templates stored in the existing OGIT NTO tree; consumers +re-instantiate any model or workflow action by rendering against the +TTL via `ogar-render-askama`, never touching Odoo Python.** + +--- + +## §1. The pipeline + +``` + Odoo Python source + (addons//models/*.py) + │ + │ ogar-from-python (AST schema filter) + │ — keeps structural arm: _name, _inherit, fields.*, selections + │ — keeps behavioural-arm SIGNATURES (decorators, action def names) + │ — drops bodies (computed methods, action implementations) + ▼ + OGAR Class IR (in memory) + │ + │ ttl_emit::emit_entity (semantic bijection) + │ — entity-as-class for models + │ — verb-as-class for workflow action signatures + ▼ + OGIT-shaped TTL templates + (vocab/imports/ogit/NTO//.ttl) + — dcterms:creator = bus-compiler (digester provenance) + — alongside upstream arago TTL (Viktor Voss et al) + │ + │ ogar-render-askama (entity render → views; verb render → actions) + ▼ + Materialized output (any consumer, any medium) + — HTML view (per-app skin) + — JSON / OpenAPI surface + — SurrealQL DDL / Postgres CREATE TABLE + — SPO triple emit + ACL gate + audit record (action render) +``` + +The Python runtime is **only** touched at digest time. Consumers +(`woa-rs`, `smb-office-rs`, `medcare-rs`, `q2`, any future renderer) +never depend on Odoo Python, only on TTL + the askama renderer. + +## §2. Why store digests in OGIT NTO (not a parallel `vocab/imports/odoo/`) + +The `dcterms:creator` author-scan (`OGIT-DOMAIN-LIFT-CATALOGUE.md +§ Verifying domain authorship`) gives clean provenance without +needing a separate storage namespace: + +| `dcterms:creator` value | Meaning | Who can change | +|---|---|---| +| `Viktor Voss`, `chris.boos@almato.com`, `fotto@arago.de`, … | Upstream arago/almato | Re-vendor requires arago PR; OGAR consumes via the SHA pin | +| `bus-compiler`, `family-codec-smith`, `Claude (...)`, … | Internal agent digest | Re-run the digester; no external coordination | + +The precedent exists today: `vocab/imports/ogit/NTO/Accounting/` +already has 11 files from a prior `Claude (AdaWorldAPI/lance-graph +3-hop optim)` digest sitting alongside Viktor Voss's 23 originals. The +two co-exist, the author-scan distinguishes them, and lifting both +into OGAR's `Class` IR is symmetric. + +So **the digest lives where the concept belongs** — `account.move` → +`Accounting/`, `sale.order` → `SalesDistribution/`, `stock.picking` → +`Transport/` — and the author scan is the discriminator. + +## §3. The four shapes the digester produces + +| Shape | Source pattern | TTL form | Render path | +|---|---|---|---| +| **Entity-as-class** | `class Foo(models.Model): _name = '...'; ` | `rdfs:Class` + `mandatory-attributes` enumerating field names | view render (`ogar-render-askama::views`) | +| **Datatype attribute** | `field_name = fields.Char/Integer/Selection(...)` (with selection) | `owl:DatatypeProperty` + `ogit:validation-type "fixed"` + `validation-parameter "a,b,c"` | binding validation | +| **Association** | `partner_id = fields.Many2one('res.partner', ...)` | `owl:ObjectProperty` (lifted as SGO verb) OR an entry in the parent class's `ogit:allowed (...)` block | edge in lifted SPO graph | +| **Verb-as-class** | `def action_confirm(self)` workflow method | `rdfs:Class` + slot list (subject = SaleOrder, object = confirmation event) + policy attrs (`requires-perm`, `emits-audit`) | action render (`ogar-render-askama::actions`, queued) | + +The last shape is what makes "relive agnostically" possible for +workflow actions: the Python method body stays in Odoo (behavioural +arm), but the **action contract** (what it requires, what it emits, +what it depends on) becomes a typed template any consumer can render +against. + +## §4. The mapping table — v0 from the existing codebook + +The six commerce/ERP concepts already minted in `ogar-vocab::class_ids` +give the digester its first six targets. Concepts beyond these need +the 5+3 codebook pass (per `docs/APP-CLASS-CODEBOOK-LAYOUT.md §4`) +before mint; the digester can lift them in the meantime with +`Class.name` carrying the Odoo model name and no concept_id assigned. + +| Odoo model | Source addon path | OGIT NTO target | OGAR concept | Shape | Concept-mint status | +|---|---|---|---|---|---| +| `res.partner` | `addons/base/models/res_partner.py` | `Accounting/` | `BILLING_PARTY` `0x0204` | entity-as-class | minted | +| `account.move` | `addons/account/models/account_move.py` | `Accounting/` | `COMMERCIAL_DOCUMENT` `0x0202` | entity-as-class | minted | +| `account.move.line` | `addons/account/models/account_move_line.py` | `Accounting/` | `COMMERCIAL_LINE_ITEM` `0x0201` | entity-as-class | minted | +| `account.tax` | `addons/account/models/account_tax.py` | `Accounting/` | `TAX_POLICY` `0x0203` | entity-as-class | minted | +| `account.payment` | `addons/account/models/account_payment.py` | `Accounting/` | `PAYMENT_RECORD` `0x0205` | entity-as-class | minted | +| `res.currency` | `addons/base/models/res_currency.py` | `Accounting/` | `CURRENCY_POLICY` `0x0206` | entity-as-class | minted | +| `sale.order` | `addons/sale/models/sale_order.py` | `SalesDistribution/` | TBD `0x02??` | entity-as-class | needs mint | +| `sale.order.line` | `addons/sale/models/sale_order_line.py` | `SalesDistribution/` | TBD | entity-as-class | needs mint | +| `stock.picking` | `addons/stock/models/stock_picking.py` | `Transport/` | TBD | entity-as-class | needs mint | +| `hr.employee` | `addons/hr/models/hr_employee.py` | `HR/` | TBD | entity-as-class | needs mint | +| `crm.lead` | `addons/crm/models/crm_lead.py` | (new NTO?) | TBD | entity-as-class | needs mint + domain decision | +| `product.product` | `addons/product/models/product_product.py` | (new NTO?) | TBD | entity-as-class | needs mint + domain decision | +| `account.move::action_post` | workflow method on `account.move` | `Accounting/verbs/Post.ttl` | TBD | **verb-as-class** | needs mint | +| `sale.order::action_confirm` | workflow method on `sale.order` | `SalesDistribution/verbs/Confirm.ttl` | TBD | **verb-as-class** | needs mint | +| `stock.picking::action_assign` | workflow method on `stock.picking` | `Transport/verbs/Assign.ttl` | TBD | **verb-as-class** | needs mint | + +The table grows additively. Each new Odoo addon module digested by +the v0 producer adds rows; concept-mint passes work in parallel. + +## §5. The drift detector — Foundry's "ontology change management" for free + +``` +Day 1 — digest Odoo at SHA-A + ogar-from-python addons/account → vocab/imports/ogit/NTO/Accounting/*.ttl + git commit (the TTL set is the frozen contract) + +Day N — Odoo upstream releases SHA-B + ogar-from-python addons/account → /tmp/odoo-shaB-digest/ + diff -r vocab/imports/ogit/NTO/Accounting/ /tmp/odoo-shaB-digest/ + + Any output line is a structural change Odoo just made: + - added field → diff shows a new ogit:optional-attributes entry + - renamed column → diff shows the rename + - extended selection → diff shows the new validation-parameter values + - changed _inherit → diff shows the rdfs:subClassOf rewire + - dropped a model → diff shows file deletion +``` + +Same diff fires in CI on the same PR if a contributor edits an Odoo +model without re-running the digest. **The TTL templates are the +contract; the digest re-run is the audit; the diff is the gate.** +Foundry sells this as "ontology change management" for a recurring +license fee. + +## §6. What blocks doing it today + +| Piece | Status | +|---|---| +| Storage location (`vocab/imports/ogit/NTO//`) | exists; 72 domains imported, MARS oracle proven | +| TTL emitter for the structural arm | exists (`ttl_emit::emit_entity`); semantic bijection proven on 29 MARS + 176 SGO TTLs | +| Verb-as-class template surface | exists (WorkOrder convention; `docs/VERB-AS-CLASS-TEMPLATE.md`) | +| Author-provenance discriminator | exists (`dcterms:creator` scan in `OGIT-DOMAIN-LIFT-CATALOGUE.md`) | +| `ogar-render-askama::actions` (verb-as-class render path) | **does not exist** — ~200 LOC mirroring the existing `views/` path | +| `ogar-from-python` (the digester) | **does not exist** — needs `libcst` or `rustpython-parser` to walk Python AST; ~1500 LOC for the structural-arm filter | +| Concept mints for non-Accounting Odoo models | needs the 5+3 codebook pass per `APP-CLASS-CODEBOOK-LAYOUT.md` | + +`ogar-from-python` and `ogar-render-askama::actions` are independent +and can ship in parallel PRs. Concept mints are the slow path +(codebook discipline) and don't block the digest — a digested model +without a minted concept_id just gets `Class.name = "sale.order"` and +gets the id assigned later. + +## §7. The Foundry-parity collapse + +This section is the punchline. Foundry's platform pitch decomposes +into four layers; each layer maps to a free, open-source piece in +this architecture: + +| Foundry layer | Vendor cost | Our equivalent | Marginal cost | +|---|---|---|---| +| Ingest (vendor pipelines) | $ | `ogar-from-python` digest run (one-shot per Odoo upgrade) | engineer-hours per digester ~1500 LOC | +| Storage (vendor platform) | $$ | `vocab/imports/ogit/NTO//` TTL templates with `dcterms:creator` provenance | zero | +| Render (vendor UI) | $$ | `ogar-render-askama::{views, actions}` | engineer-hours per render path ~200 LOC each | +| Access control / audit (vendor IAM) | $$$ | verb-as-class `requires-perm` slot + `emits-audit` + Lance-version-as-audit (ADR-013) | zero (the substrate already does it) | +| Ontology change management (vendor feature) | $$$ | `diff -r` of digest output (§5) | zero | + +The substrate eats every layer the platform sells, **using artifacts +that already exist in this repo**, in less than 2000 lines of new +code. The architecture has been latent the whole time — your +"digest → relive agnostically" framing is what makes it visible as +one shape. + +## §8. Cross-references + +- `docs/ODOO-TRANSCODING.md` — the existing producer spec (sections 1-18: + module discovery, field type mapping, attribute kwargs, association + kwargs, enum sources, class-level metadata, state machines, `_inherit` + resolution, decorator mapping, CRUD overrides, registered prefixes, + conformance corpus). The "how" of the digest. +- `docs/VERB-AS-CLASS-TEMPLATE.md` — the askama-template framing the + digest reuses for workflow actions. +- `docs/HIRO-IN-CLASSES.md` — the bardioc-efficiency story (the same + digest pattern applied to MARS; this doc generalises it to Odoo). +- `docs/FOUNDRY-ODOO-MARS-LENS.md` — the three-postures cross-reading + (MARS frozen schema, Odoo extensible source, Foundry vendor platform). +- `docs/OGIT-DOMAIN-LIFT-CATALOGUE.md` — coverage register; new Odoo + digests advance the rows from Imported → Lift-tested → Cross-walked. +- `docs/APP-CLASS-CODEBOOK-LAYOUT.md` — the 5+3 mint protocol for + new concept_ids (the §6 slow path). +- `crates/ogar-from-schema/` — the schema-arm producer (TTL today; + XSD queued) that the Odoo digester pairs with on the structural + boundary. +- `crates/ogar-render-askama/` — the askama renderer (views today; + actions queued). diff --git a/docs/OGIT-DOMAIN-LIFT-CATALOGUE.md b/docs/OGIT-DOMAIN-LIFT-CATALOGUE.md index 5f81364..6066bab 100644 --- a/docs/OGIT-DOMAIN-LIFT-CATALOGUE.md +++ b/docs/OGIT-DOMAIN-LIFT-CATALOGUE.md @@ -17,12 +17,46 @@ | **Production** | A consumer deployment exercises the lifted form (see `DOMAIN-INSTANCES.md`) | A domain advances Imported → Lift-tested → Cross-walked → Production -left-to-right. All 72 are Imported today (just landed). MARS is the -first Lift-tested. OpenProject/Odoo/Healthcare are Production via the +left-to-right. All 72 are Imported today (just landed). The following +**10 domains are Lift-tested** (round-trip mechanically enforced by +`ttl_emit::tests::nine_domains_lift_surface_round_trip` + +`all_mars_ttl_files_roundtrip`): MARS, Transport, Accounting, +SalesDistribution, Credit, Cost, ServiceManagement, WorkOrder, +Compliance, Audit. OpenProject/Odoo/Healthcare are Production via the existing canonical concept work but use the source-AST lift, not the schema lift — they enter Lift-tested when their TTLs are added to the round-trip stress test. +## Verifying domain authorship (who can change what) + +Provenance is `dcterms:creator` on each TTL. Run: + +```bash +python3 - <<'PY' +import os, re +from collections import Counter +creator_re = re.compile(r'dcterms:creator\s+"([^"]+)"') +for d in sorted(os.listdir('vocab/imports/ogit/NTO')): + root = f'vocab/imports/ogit/NTO/{d}' + authors = Counter() + for r,_,fs in os.walk(root): + for f in fs: + if not f.endswith('.ttl'): continue + with open(os.path.join(r,f)) as fh: + for m in creator_re.finditer(fh.read()): + authors[m.group(1)] += 1 + if not authors: continue + top = ', '.join(f'{a} ({c})' for a,c in authors.most_common(5)) + print(f'{d:<28} {top}') +PY +``` + +Internal-agent authors (`bus-compiler`, `family-codec-smith`, `Claude +(...)`, etc.) signal "our extension — we can revise without external +coordination." External authors (`chris.boos@almato.com`, `Viktor Voss`, +`fotto@arago.de`, …) signal "upstream-owned — structural changes need +arago/almato coordination." + ## How to add a new domain to the lift 1. **Verify import** — `ls vocab/imports/ogit/NTO//`. If @@ -44,16 +78,16 @@ round-trip stress test. | Domain | Entities | Attributes | Verbs | Status | Notes | |---|--:|--:|--:|---|---| -| `Accounting` | 9 | 20 | 7 | Imported | Covered conceptually via `0x02XX` commerce/ERP via Odoo lift | +| `Accounting` | 9 | 20 | 7 | Lift-tested | Mixed-authorship: `Viktor Voss` (23 files, original arago/almato) + a prior session's extension (`Claude (AdaWorldAPI/lance-graph 3-hop optim)`, 11 files). Covered conceptually via `0x02XX` commerce/ERP via Odoo lift. Structural changes to the original 23 need upstream coordination; the 11 extensions are ours. | | `Advertising` | 16 | 0 | 0 | Imported | | -| `Audit` | 3 | 0 | 0 | Imported | Audit-as-Lance-version (ADR-013) covers the semantics | +| `Audit` | 3 | 0 | 0 | Lift-tested | `Marek Meyer` (sole author) — pure upstream. Audit-as-Lance-version (ADR-013) covers the semantics. | | `Auth` | 13 | 24 | 6 | Imported | Cross-walk to `0x0BXX` auth domain (Zitadel/Zanzibar) queued | | `Automation` | 22 | 105 | 0 | Imported | OLD `marsNodeType` superseded by `NTO/MARS/` | | `Botany` | 2 | 0 | 0 | Imported | | | `ClassificationStandard` | 2 | 5 | 2 | Imported | | -| `Compliance` | 1 | 4 | 4 | Imported | | -| `Cost` | 5 | 0 | 0 | Imported | | -| `Credit` | 12 | 0 | 9 | Imported | | +| `Compliance` | 1 | 4 | 4 | Lift-tested | `chris.boos@almato.com` (sole author) — pure upstream | +| `Cost` | 5 | 0 | 0 | Lift-tested | `Peter Larem` (sole author) — pure upstream | +| `Credit` | 12 | 0 | 9 | Lift-tested | `Ola Irgens Kylling` (sole author, 21 files) — pure upstream; capitalised `Entities/` + `Verbs/` dirs (content-driven parser is dir-case-agnostic) | | `CustomerSupport` | 7 | 31 | 2 | Imported | | | `Data` | 1 | 1 | 0 | Imported | | | `DataProcessing` | 2 | 6 | 0 | Imported | | @@ -104,18 +138,18 @@ round-trip stress test. | `RPA` | 6 | 1 | 1 | Imported | | | `Religion` | 1 | 0 | 0 | Imported | | | `SaaS` | 10 | 12 | 0 | Imported | | -| `SalesDistribution` | 12 | 11 | 0 | Imported | | +| `SalesDistribution` | 12 | 11 | 0 | Lift-tested | `Marek Meyer` (sole author, 23 files) — pure upstream | | `Schedule` | 5 | 7 | 0 | Imported | | | `Security` | 2 | 0 | 0 | Imported | | -| `ServiceManagement` | 17 | 42 | 0 | Imported | MARS Machine `generates` Log/Timeseries lands here | +| `ServiceManagement` | 17 | 42 | 0 | Lift-tested | 8 distinct authors led by `Peter Larem` (42 files); pure upstream. MARS Machine `generates` Log/Timeseries lands here. | | `SharePoint` | 0 | 2 | 0 | Imported | Attributes-only | | `Software` | 5 | 0 | 0 | Imported | Distinct from `NTO/MARS/Software/` — this is a software-engineering vocabulary | | `Statistics` | 1 | 0 | 0 | Imported | | | `Survey` | 3 | 0 | 0 | Imported | | -| `Transport` | 5 | 14 | 8 | Imported | | +| `Transport` | 5 | 14 | 8 | Lift-tested | `chris.boos@almato.com` (sole author, 27 files) — pure upstream-arago | | `UserMeta` | 4 | 0 | 4 | Imported | | | `Version` | 0 | 3 | 0 | Imported | Used by MARS Machine for OS version | -| `WorkOrder` | 15 | 0 | 12 | Imported | Covered conceptually by WoA (`0x0003`) | +| **`WorkOrder`** | 27 | 0 | 0 | **Lift-tested** | **Our extension** (`dcterms:creator` = `bus-compiler` + `family-codec-smith` — internal agent authors, zero external). Authored for `woa-rs`. All 27 TTLs declared as `rdfs:Class`, including the 12 in `verbs/`. **The `rdfs:Class`-as-verb convention is deliberate, not a quirk** — it makes each verb a typed template (slots, inheritance, policy metadata) that `ogar-render-askama` can compile-time-validate against a binding, the same way askama validates HTML templates against a Rust struct. See `docs/VERB-AS-CLASS-TEMPLATE.md`. Previous catalogue row split 15 entities + 12 verbs by directory; the content-driven count is 27 first-class typed declarations (entities + verb-as-class templates), which is what `ogar-from-schema` sees and what the action-render path consumes. | | **TOTALS** | **549** | **599** | **241** | — | + 42 other (Medical sql_mirror, etc.) | ## Adjacent imports (not NTO) diff --git a/docs/VERB-AS-CLASS-TEMPLATE.md b/docs/VERB-AS-CLASS-TEMPLATE.md new file mode 100644 index 0000000..7da71ef --- /dev/null +++ b/docs/VERB-AS-CLASS-TEMPLATE.md @@ -0,0 +1,159 @@ +# Verb-as-class — the ontological askama/jinja + +> **Insight (operator, 2026-06-22).** When a verb is encoded as +> `rdfs:Class` instead of `owl:ObjectProperty`, the TTL file becomes +> a **compile-time-validated action template** — the ontological +> counterpart to askama (Rust) and jinja (Python) HTML templating. +> WorkOrder uses this convention (12 verbs declared as classes); the +> existing `ogar-render-askama` crate is the natural integration +> point. +> +> Status: **FRAMING v0** (2026-06-22). Companion to +> `docs/OGIT-DOMAIN-LIFT-CATALOGUE.md` (WorkOrder row) and +> `docs/FOUNDRY-ODOO-MARS-LENS.md` (the Foundry-parity angle). + +The convention in one sentence: **a `rdfs:Class` verb declares a typed +slot list; a render takes a context binding and produces a materialised +SPO triple + declared side effects; the engine validates slot↔binding +at the same point askama validates `{{ name }}` against the struct +field.** + +## The two encodings compared + +| | `owl:ObjectProperty` verb | `rdfs:Class` verb | +|---|---|---| +| Carries | name + description | name + description + **slot list** + **inheritance** + **policy attributes** | +| Subject/object types | implied (by domain/range, often missing in OGIT) | explicit (`ogit:mandatory-attributes` enumerates the slots) | +| Inheritance | not native | `rdfs:subClassOf` gives template inheritance | +| Policy metadata | not native | any attribute the consumer wants (`ogit:requires-perm`, `ogit:emits-audit`) | +| Compile-time slot validation | no — verbs are flat names | yes — `ogar-from-schema` lifts the slot list; the renderer checks bindings | +| Round-trip with `ogar-from-schema` | yes (via `sgo::parse_verb`) | yes (via `ttl::parse_file` as Entity) | +| **Renders as** | a label on an edge | a typed action with side effects | + +Both encodings round-trip cleanly today. The choice is about **what the +verb is *for*** — a flat predicate (use `owl:ObjectProperty`, like +SGO's 176) or a typed action template (use `rdfs:Class`, like WorkOrder's 12). + +## Term-for-term with askama / jinja + +``` +askama / jinja │ ontology (verb-as-class) +───────────────────────────── │ ───────────────────────────────────────── +template.html.j2 │ vocab/imports/ogit/NTO//verbs/.ttl + │ a rdfs:Class +struct Context { name: String │ ogit:mandatory-attributes ( + │ ogit:subject + │ ogit:object + │ … + │ ) +context binding │ per-call value map +{% extends "base.html.j2" %} │ rdfs:subClassOf ogit:AuditableAction +filters / macros │ ogit:requires-perm, ogit:emits-audit +compile-time slot check │ ogar-from-schema validates the slot list + │ against the binding at lift time +render() → HTML string │ render() → (SPO triple, audit record, + │ ACL decision, side effects) +``` + +The structural correspondence is exact. Both are +**compile-time-validated declarative templates**; both separate +"template" (TTL file / `.html.j2`) from "context" (binding / struct); +both surface invalid bindings before render. The output medium +differs (HTML string vs. graph delta) but the engine shape is the same. + +## A worked example — `WorkOrder/verbs/AccessesPortal.ttl` + +```turtle +ogit.WorkOrder:AccessesPortal + a rdfs:Class; # VERB-AS-CLASS + rdfs:subClassOf ogit:AuditableAction; # inherits audit slot + rdfs:label "AccessesPortal"; + ogit:mandatory-attributes ( # SLOTS — like askama struct fields + ogit:subject # who + ogit:object # what portal + ogit:timestamp # when + ); + ogit:requires-perm "portal_login"; # template metadata + ogit:emits-audit "true"; # render-time side effect +. +``` + +When `User#42 accesses Portal#1` at time `T`: + +1. **Lookup** — the renderer resolves `AccessesPortal` to its lifted + `Class` (the template). +2. **Bind** — the binding `{ subject: User#42, object: Portal#1, + timestamp: T }` is type-checked against the slot list. Missing + `timestamp` ⇒ render-time error (same as askama: missing struct field + ⇒ compile error). +3. **Render** — emit: + - SPO triple `(User#42, AccessesPortal#, Portal#1)` with + `timestamp = T` + - Audit record (because `emits-audit = true`) + - ACL gate (must satisfy `requires-perm = "portal_login"`) + +The output is a **graph delta + side-effect spec**, not a string. + +## Why this is the right integration point for `ogar-render-askama` + +The crate already does askama-template rendering for `Class` **views** +(per-app skin per `docs/OGAR-CONSUMER-BEST-PRACTICES.md`). Verb-as-class +is the parallel render path for `Class` **actions**: + +``` +ogar-render-askama/ +├── views/ — Class views (HTML, JSON, OpenAPI) ← EXISTING +└── actions/ — Class actions (verb-as-class render) ← NEW (this framing) +``` + +Same `Class` IR, same askama engine, same compile-time-validated +template/binding pattern. Dispatch is on what shape the `Class` +declares: noun-shaped (entity fields) → view render; verb-shaped +(`mandatory-attributes` = subject/object/timestamp/…) → action render. + +## The Foundry-parity sharpening + +Foundry's "action types" carry exactly the four properties listed in +the comparison table: + +1. Typed parameters (slots) +2. Compile-time validation of bindings +3. Declared side effects (audit, ACL, downstream effects) +4. Inheritance / composition + +Foundry sells those as a paid platform feature. Verb-as-class TTL + +`ogar-render-askama` gives the same four properties from +**open-source schemas and Rust templates** — no vendor, no lock. + +## What this changes for the next session + +Three small implications: + +1. **Don't normalise WorkOrder's convention** to `owl:ObjectProperty`. + The earlier framing (in the commit message of `cce8420`) was wrong; + re-encoding would strip the template surface. + +2. **The `actions/` submodule in `ogar-render-askama` is the natural + landing** for the verb-as-class renderer. Not implemented today; + ~200 LOC mirroring the existing `views/` render path. + +3. **The verb-as-class convention is a candidate for prototyping + in WorkOrder first** (since we're upstream — `dcterms:creator` = + `bus-compiler` / `family-codec-smith`), then pitching to OGIT + upstream once `ogar-render-askama`'s actions path has proven the + pattern. + +## Cross-references + +- `crates/ogar-render-askama/` — the existing askama renderer (views + today; actions tomorrow) +- `crates/ogar-from-schema/` — the lift that turns TTL into the IR + the renderer consumes +- `docs/OGIT-DOMAIN-LIFT-CATALOGUE.md` — WorkOrder row with the + verb-as-class convention note +- `docs/FOUNDRY-ODOO-MARS-LENS.md` — the Foundry-parity argument + that verb-as-class sharpens +- `docs/OGAR-CONSUMER-BEST-PRACTICES.md` — the per-app `ClassView` + pattern that views-side rendering already follows +- `vocab/imports/ogit/NTO/WorkOrder/verbs/*.ttl` — the 12 verb-as-class + templates this framing describes