diff --git a/crates/ogar-emitter/src/lib.rs b/crates/ogar-emitter/src/lib.rs index a5c5041..03dd0f1 100644 --- a/crates/ogar-emitter/src/lib.rs +++ b/crates/ogar-emitter/src/lib.rs @@ -847,6 +847,48 @@ fn kausal_triples(action_subject: &str, k: &ogar_vocab::KausalSpec) -> Vec { + let mut v = vec![Triple::new( + action_subject, + "ogar:kausalKind", + "ogar:Constrains", + )]; + for p in paths { + if p.contains('.') { + continue; + } + v.push(Triple::new( + action_subject, + "ogar:kausalConstrainsPath", + p.clone(), + )); + } + v + } + KausalSpec::Onchange { paths } => { + let mut v = vec![Triple::new( + action_subject, + "ogar:kausalKind", + "ogar:Onchange", + )]; + for p in paths { + if p.contains('.') { + continue; + } + v.push(Triple::new( + action_subject, + "ogar:kausalOnchangePath", + p.clone(), + )); + } + v + } KausalSpec::ContextDepends { keys } => { let mut v = vec![Triple::new( action_subject, @@ -1697,39 +1739,119 @@ mod tests { t.iter() .any(|t| t.predicate == "ogar:kausalKind" && t.object == "ogar:External") ); + + def.kausal = Some(ogar_vocab::KausalSpec::constrains(vec!["state".into()])); + let t = TripleEmitter::emit_action_def(&def); + assert!( + t.iter() + .any(|t| t.predicate == "ogar:kausalKind" && t.object == "ogar:Constrains") + ); + assert!( + t.iter() + .any(|t| t.predicate == "ogar:kausalConstrainsPath" && t.object == "state") + ); + + def.kausal = Some(ogar_vocab::KausalSpec::onchange(vec!["partner_id".into()])); + let t = TripleEmitter::emit_action_def(&def); + assert!( + t.iter() + .any(|t| t.predicate == "ogar:kausalKind" && t.object == "ogar:Onchange") + ); + assert!( + t.iter() + .any(|t| t.predicate == "ogar:kausalOnchangePath" && t.object == "partner_id") + ); } #[test] - fn kausal_constrains_onchange_currently_emit_unknown_pending_emitter_wiring() { - // CHARACTERIZATION (not aspiration): SPEC-ATC2-OGAR §6 defers the TTL - // emitter wiring for the Arm B variants, so `kausal_triples` has no - // Constrains/Onchange arm — they fall through the mandatory wildcard - // (`KausalSpec` is #[non_exhaustive], so cross-crate matches CANNOT be - // wildcard-free; the `kausal_spec_match_is_exhaustive` fuse only - // protects ogar-vocab, never this consumer). The IR struct carries the - // paths correctly; the TTL projection currently drops them and labels - // the kind `ogar:Unknown`. This test PINS that lossy interim so the - // drop is documented, not silent — when §6 lands (predicates - // `ogar:Constrains`/`ogar:Onchange` + a path predicate), this test - // fails loudly and forces the implementer to update it. See ledger - // D-ATC2-KAUSAL-RUFF-GATED. - for k in [ - ogar_vocab::KausalSpec::constrains(vec!["state".into()]), - ogar_vocab::KausalSpec::onchange(vec!["partner_id".into()]), + fn kausal_constrains_onchange_emit_declared_kinds_and_paths() { + // §6-Mint (SPEC-MINT-ARM-B-TTL): Constrains/Onchange now emit their + // own declared kausalKind + kausal-scoped path predicates, replacing + // the prior ogar:Unknown characterization (D-ATC2-KAUSAL-RUFF-GATED, + // debt resolved per 5+3-Council GO). + for (k, expected_kind, expected_path_predicate, path) in [ + ( + ogar_vocab::KausalSpec::constrains(vec!["state".into()]), + "ogar:Constrains", + "ogar:kausalConstrainsPath", + "state", + ), + ( + ogar_vocab::KausalSpec::onchange(vec!["partner_id".into()]), + "ogar:Onchange", + "ogar:kausalOnchangePath", + "partner_id", + ), ] { let mut def = ogar_vocab::ActionDef::new("a", "p", "ogit-op/Foo"); def.kausal = Some(k); let t = TripleEmitter::emit_action_def(&def); + + assert!( + t.iter() + .any(|t| t.predicate == "ogar:kausalKind" && t.object == expected_kind), + "expected kausalKind {expected_kind}" + ); assert!( t.iter() + .any(|t| t.predicate == expected_path_predicate && t.object == path), + "expected {expected_path_predicate} triple for {path}" + ); + + // Negative guards (Konflations-Fuse, Codex-Regel 2026-06-04): + // Arm B must never fall back to Unknown, and must never reuse + // the ComputedField or Depends path predicates. + assert!( + !t.iter() .any(|t| t.predicate == "ogar:kausalKind" && t.object == "ogar:Unknown"), - "interim: unwired Arm B variants label as ogar:Unknown" + "Arm B variants must not label as ogar:Unknown" + ); + assert!( + !t.iter().any(|t| t.predicate == "ogar:dependsPath"), + "Arm B variants must not emit the ComputedField ogar:dependsPath" + ); + assert!( + !t.iter().any(|t| t.predicate == "ogar:kausalDependsPath"), + "Arm B variants must not emit the Depends-scoped ogar:kausalDependsPath" + ); + } + } + + #[test] + fn kausal_constrains_onchange_drop_dotted_paths_without_triple() { + // Council-S5 pathology: Odoo silently ignores dotted paths in + // `@api.constrains` / `@api.onchange` (odoo/orm/decorators.py: + // 106-108/213-215). Mirror that: drop-with-no-triple for the dotted + // path, but the kausalKind triple still stands — NOT ogar:Unknown. + for (k, expected_kind, expected_path_predicate) in [ + ( + ogar_vocab::KausalSpec::constrains(vec!["partner_id.name".into()]), + "ogar:Constrains", + "ogar:kausalConstrainsPath", + ), + ( + ogar_vocab::KausalSpec::onchange(vec!["partner_id.name".into()]), + "ogar:Onchange", + "ogar:kausalOnchangePath", + ), + ] { + let mut def = ogar_vocab::ActionDef::new("a", "p", "ogit-op/Foo"); + def.kausal = Some(k); + let t = TripleEmitter::emit_action_def(&def); + + assert!( + t.iter() + .any(|t| t.predicate == "ogar:kausalKind" && t.object == expected_kind), + "kausalKind triple must remain even when the only path is dotted" + ); + assert!( + !t.iter().any(|t| t.predicate == expected_path_predicate), + "dotted path must be dropped, not emitted as a path triple" ); - // The field paths are NOT projected to TTL yet (the debt). assert!( !t.iter() - .any(|t| t.object == "state" || t.object == "partner_id"), - "interim: Arm B field paths are dropped at TTL emission (§6 deferred)" + .any(|t| t.predicate == "ogar:kausalKind" && t.object == "ogar:Unknown"), + "dropped dotted path must not reclassify the kind as ogar:Unknown" ); } } diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index d9eb591..afc71e1 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -1125,3 +1125,33 @@ isolation. The map's job is to keep them visible. + a kausal path predicate — a governed vocab mint, NOT done here). - Additive API gap closed: `KausalSpec::{constrains,onchange}` constructors added to mirror `depends()`/`lifecycle()` (consumers + the test need them). + - **DEBT RESOLVED (§6-Mint, 2026-07-07, 5+3-Council GO; + SPEC-MINT-ARM-B-TTL).** The P2 above is closed: `ogar-emitter:: + kausal_triples` now carries `KausalSpec::{Constrains,Onchange}` arms + emitting `ogar:Constrains` / `ogar:Onchange` + `ogar:kausalConstrainsPath` + / `ogar:kausalOnchangePath` per field path (the `_ => ogar:Unknown` + wildcard still stands, now covering only genuine future variants). + `vocab/ogar.ttl` carries the matching registry in lockstep (two new + `ogar:KausalKind` instances + two new `rdf:Property` path predicates, + same pattern as the five pre-existing instances) — the Council-S3 + "no separate registry" correction from this same entry, now actually + closed. Council-B1 nebenbefund folded in: `ogar:Unknown` is now ALSO + declared `a ogar:KausalKind` (it was only ever `a ogar:EnumSourceKind`; + the wildcard fallback was emitting an undeclared kind IRI all along — + a pre-existing declaration gap, not new behaviour). Dotted paths + (Council-S5: Odoo silently drops them in `@api.constrains` / + `@api.onchange`, `odoo/orm/decorators.py:106-108/213-215`) are + drop-with-no-triple for that path; the `kausalKind` triple still + stands, NOT `ogar:Unknown`. The characterization test + `kausal_constrains_onchange_currently_emit_unknown_pending_emitter_wiring` + flipped as designed — replaced by + `kausal_constrains_onchange_emit_declared_kinds_and_paths` (positive + kausalKind + path assertions, plus the Konflations-Fuse negative + guards: no `ogar:Unknown`, no `ogar:dependsPath`, no + `ogar:kausalDependsPath`) and + `kausal_constrains_onchange_drop_dotted_paths_without_triple`; + `kausal_spec_variants_emit_distinct_kinds` extended to cover both + variants. Roundtrip: `ogar-adapter-ttl` has no kausal-consumer/parser + (verified — its module doc lists `ActionDef` / `KausalSpec` under + "Not yet supported"), so TTL stays write-only for kausal; no + roundtrip case added, per the spec's documented fallback. diff --git a/vocab/ogar.ttl b/vocab/ogar.ttl index 95a25b6..08d9a30 100644 --- a/vocab/ogar.ttl +++ b/vocab/ogar.ttl @@ -303,6 +303,9 @@ ogar:LifecycleTrigger a ogar:KausalKind . ogar:Depends a ogar:KausalKind . ogar:ContextDepends a ogar:KausalKind . ogar:External a ogar:KausalKind . +ogar:Constrains a ogar:KausalKind . +ogar:Onchange a ogar:KausalKind . +ogar:Unknown a ogar:KausalKind . ogar:guardField a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string . ogar:guardValue a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string . ogar:triggerEvent a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string . @@ -311,6 +314,8 @@ ogar:triggerEvent a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range # action is NOT inferred as a ComputedField. Codex review. ogar:kausalDependsPath a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string . ogar:kausalDependsContext a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string . +ogar:kausalConstrainsPath a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string . +ogar:kausalOnchangePath a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string . # Enumerations ogar:ActionSubjectKind a owl:Class .