@@ -847,6 +847,48 @@ fn kausal_triples(action_subject: &str, k: &ogar_vocab::KausalSpec) -> Vec<Tripl
847847 }
848848 v
849849 }
850+ // `@api.constrains` / `@api.onchange` (SPEC-ATC2-OGAR Arm B) — kept
851+ // kausal-scoped (ogar:kausalConstrainsPath / ogar:kausalOnchangePath),
852+ // never the ComputedField predicates, for the same reason as Depends
853+ // above. Odoo silently drops dotted paths in `@api.constrains` /
854+ // `@api.onchange` (odoo/orm/decorators.py:106-108/213-215); mirror
855+ // that here: skip the path triple, keep the kausalKind triple.
856+ KausalSpec :: Constrains { paths } => {
857+ let mut v = vec ! [ Triple :: new(
858+ action_subject,
859+ "ogar:kausalKind" ,
860+ "ogar:Constrains" ,
861+ ) ] ;
862+ for p in paths {
863+ if p. contains ( '.' ) {
864+ continue ;
865+ }
866+ v. push ( Triple :: new (
867+ action_subject,
868+ "ogar:kausalConstrainsPath" ,
869+ p. clone ( ) ,
870+ ) ) ;
871+ }
872+ v
873+ }
874+ KausalSpec :: Onchange { paths } => {
875+ let mut v = vec ! [ Triple :: new(
876+ action_subject,
877+ "ogar:kausalKind" ,
878+ "ogar:Onchange" ,
879+ ) ] ;
880+ for p in paths {
881+ if p. contains ( '.' ) {
882+ continue ;
883+ }
884+ v. push ( Triple :: new (
885+ action_subject,
886+ "ogar:kausalOnchangePath" ,
887+ p. clone ( ) ,
888+ ) ) ;
889+ }
890+ v
891+ }
850892 KausalSpec :: ContextDepends { keys } => {
851893 let mut v = vec ! [ Triple :: new(
852894 action_subject,
@@ -1697,39 +1739,119 @@ mod tests {
16971739 t. iter( )
16981740 . any( |t| t. predicate == "ogar:kausalKind" && t. object == "ogar:External" )
16991741 ) ;
1742+
1743+ def. kausal = Some ( ogar_vocab:: KausalSpec :: constrains ( vec ! [ "state" . into( ) ] ) ) ;
1744+ let t = TripleEmitter :: emit_action_def ( & def) ;
1745+ assert ! (
1746+ t. iter( )
1747+ . any( |t| t. predicate == "ogar:kausalKind" && t. object == "ogar:Constrains" )
1748+ ) ;
1749+ assert ! (
1750+ t. iter( )
1751+ . any( |t| t. predicate == "ogar:kausalConstrainsPath" && t. object == "state" )
1752+ ) ;
1753+
1754+ def. kausal = Some ( ogar_vocab:: KausalSpec :: onchange ( vec ! [ "partner_id" . into( ) ] ) ) ;
1755+ let t = TripleEmitter :: emit_action_def ( & def) ;
1756+ assert ! (
1757+ t. iter( )
1758+ . any( |t| t. predicate == "ogar:kausalKind" && t. object == "ogar:Onchange" )
1759+ ) ;
1760+ assert ! (
1761+ t. iter( )
1762+ . any( |t| t. predicate == "ogar:kausalOnchangePath" && t. object == "partner_id" )
1763+ ) ;
17001764 }
17011765
17021766 #[ test]
1703- fn kausal_constrains_onchange_currently_emit_unknown_pending_emitter_wiring ( ) {
1704- // CHARACTERIZATION (not aspiration): SPEC-ATC2-OGAR §6 defers the TTL
1705- // emitter wiring for the Arm B variants, so `kausal_triples` has no
1706- // Constrains/Onchange arm — they fall through the mandatory wildcard
1707- // (`KausalSpec` is #[non_exhaustive], so cross-crate matches CANNOT be
1708- // wildcard-free; the `kausal_spec_match_is_exhaustive` fuse only
1709- // protects ogar-vocab, never this consumer). The IR struct carries the
1710- // paths correctly; the TTL projection currently drops them and labels
1711- // the kind `ogar:Unknown`. This test PINS that lossy interim so the
1712- // drop is documented, not silent — when §6 lands (predicates
1713- // `ogar:Constrains`/`ogar:Onchange` + a path predicate), this test
1714- // fails loudly and forces the implementer to update it. See ledger
1715- // D-ATC2-KAUSAL-RUFF-GATED.
1716- for k in [
1717- ogar_vocab:: KausalSpec :: constrains ( vec ! [ "state" . into( ) ] ) ,
1718- ogar_vocab:: KausalSpec :: onchange ( vec ! [ "partner_id" . into( ) ] ) ,
1767+ fn kausal_constrains_onchange_emit_declared_kinds_and_paths ( ) {
1768+ // §6-Mint (SPEC-MINT-ARM-B-TTL): Constrains/Onchange now emit their
1769+ // own declared kausalKind + kausal-scoped path predicates, replacing
1770+ // the prior ogar:Unknown characterization (D-ATC2-KAUSAL-RUFF-GATED,
1771+ // debt resolved per 5+3-Council GO).
1772+ for ( k, expected_kind, expected_path_predicate, path) in [
1773+ (
1774+ ogar_vocab:: KausalSpec :: constrains ( vec ! [ "state" . into( ) ] ) ,
1775+ "ogar:Constrains" ,
1776+ "ogar:kausalConstrainsPath" ,
1777+ "state" ,
1778+ ) ,
1779+ (
1780+ ogar_vocab:: KausalSpec :: onchange ( vec ! [ "partner_id" . into( ) ] ) ,
1781+ "ogar:Onchange" ,
1782+ "ogar:kausalOnchangePath" ,
1783+ "partner_id" ,
1784+ ) ,
17191785 ] {
17201786 let mut def = ogar_vocab:: ActionDef :: new ( "a" , "p" , "ogit-op/Foo" ) ;
17211787 def. kausal = Some ( k) ;
17221788 let t = TripleEmitter :: emit_action_def ( & def) ;
1789+
1790+ assert ! (
1791+ t. iter( )
1792+ . any( |t| t. predicate == "ogar:kausalKind" && t. object == expected_kind) ,
1793+ "expected kausalKind {expected_kind}"
1794+ ) ;
17231795 assert ! (
17241796 t. iter( )
1797+ . any( |t| t. predicate == expected_path_predicate && t. object == path) ,
1798+ "expected {expected_path_predicate} triple for {path}"
1799+ ) ;
1800+
1801+ // Negative guards (Konflations-Fuse, Codex-Regel 2026-06-04):
1802+ // Arm B must never fall back to Unknown, and must never reuse
1803+ // the ComputedField or Depends path predicates.
1804+ assert ! (
1805+ !t. iter( )
17251806 . any( |t| t. predicate == "ogar:kausalKind" && t. object == "ogar:Unknown" ) ,
1726- "interim: unwired Arm B variants label as ogar:Unknown"
1807+ "Arm B variants must not label as ogar:Unknown"
1808+ ) ;
1809+ assert ! (
1810+ !t. iter( ) . any( |t| t. predicate == "ogar:dependsPath" ) ,
1811+ "Arm B variants must not emit the ComputedField ogar:dependsPath"
1812+ ) ;
1813+ assert ! (
1814+ !t. iter( ) . any( |t| t. predicate == "ogar:kausalDependsPath" ) ,
1815+ "Arm B variants must not emit the Depends-scoped ogar:kausalDependsPath"
1816+ ) ;
1817+ }
1818+ }
1819+
1820+ #[ test]
1821+ fn kausal_constrains_onchange_drop_dotted_paths_without_triple ( ) {
1822+ // Council-S5 pathology: Odoo silently ignores dotted paths in
1823+ // `@api.constrains` / `@api.onchange` (odoo/orm/decorators.py:
1824+ // 106-108/213-215). Mirror that: drop-with-no-triple for the dotted
1825+ // path, but the kausalKind triple still stands — NOT ogar:Unknown.
1826+ for ( k, expected_kind, expected_path_predicate) in [
1827+ (
1828+ ogar_vocab:: KausalSpec :: constrains ( vec ! [ "partner_id.name" . into( ) ] ) ,
1829+ "ogar:Constrains" ,
1830+ "ogar:kausalConstrainsPath" ,
1831+ ) ,
1832+ (
1833+ ogar_vocab:: KausalSpec :: onchange ( vec ! [ "partner_id.name" . into( ) ] ) ,
1834+ "ogar:Onchange" ,
1835+ "ogar:kausalOnchangePath" ,
1836+ ) ,
1837+ ] {
1838+ let mut def = ogar_vocab:: ActionDef :: new ( "a" , "p" , "ogit-op/Foo" ) ;
1839+ def. kausal = Some ( k) ;
1840+ let t = TripleEmitter :: emit_action_def ( & def) ;
1841+
1842+ assert ! (
1843+ t. iter( )
1844+ . any( |t| t. predicate == "ogar:kausalKind" && t. object == expected_kind) ,
1845+ "kausalKind triple must remain even when the only path is dotted"
1846+ ) ;
1847+ assert ! (
1848+ !t. iter( ) . any( |t| t. predicate == expected_path_predicate) ,
1849+ "dotted path must be dropped, not emitted as a path triple"
17271850 ) ;
1728- // The field paths are NOT projected to TTL yet (the debt).
17291851 assert ! (
17301852 !t. iter( )
1731- . any( |t| t. object == "state" || t. object == "partner_id " ) ,
1732- "interim: Arm B field paths are dropped at TTL emission (§6 deferred) "
1853+ . any( |t| t. predicate == "ogar:kausalKind" && t. object == "ogar:Unknown " ) ,
1854+ "dropped dotted path must not reclassify the kind as ogar:Unknown "
17331855 ) ;
17341856 }
17351857 }
0 commit comments