4545//! - `Model::functions` IS now lifted — by [`lift_actions`] (the DO-arm)
4646//! to a standalone `Vec<ActionDef>`, not onto `Class` (the OGAR `Class`
4747//! is the THING/THINK shape; actions register on the DO-arm
48- //! separately). Each method's `reads` / `raises` / `traverses` edges
49- //! stay on the narrow / SPO arm as triples — `lift_actions` does not
50- //! duplicate them, nor claim a reactive dependency plain Rails methods
51- //! don't declare.
48+ //! separately). Each method's `reads` / `writes` / `calls` / `raises`
49+ //! edges ride `ActionDef` as effect annotations; `traverses` still has
50+ //! no `ActionDef` slot and stays on the narrow / SPO arm as triples only
51+ //! — `lift_actions` does not duplicate it, nor claim a reactive
52+ //! dependency plain Rails methods don't declare.
5253//! - `Model::delegations`, `Model::dsl_calls`, `Model::gem_dsl`,
5354//! `Model::dynamic_methods`, `Model::refinements` — ruff's
5455//! long-tail. OGAR doesn't model them yet; can land as
@@ -70,9 +71,11 @@ pub mod emit;
7071pub mod mint;
7172pub mod sqlalchemy; // WS-G-D
7273
74+ use std:: collections:: HashMap ;
75+
7376use ogar_vocab:: {
7477 canonical_concept, ActionDef , Association , AssociationKind , Attribute , Callback , Class ,
75- ComputedField , EnumDecl , EnumSource , Inheritance , Language , Scope , Validation ,
78+ ComputedField , EnumDecl , EnumSource , Inheritance , KausalSpec , Language , Scope , Validation ,
7679} ;
7780use ruff_spo_triplet:: {
7881 AssocDecl , AssocKind , AttrDecl , AttrKind , Callback as RuffCallback , ConcernKind , Model ,
@@ -517,11 +520,15 @@ pub fn project_canonical_roles(class: &Class) -> std::collections::HashSet<&'sta
517520///
518521/// This is a **facts-only** lift: it sets the action's `identity`,
519522/// `predicate` (the method name — already snake_case on the Rails side),
520- /// `object_class` (the model name), and the `reads` / `writes` / `calls`
523+ /// `object_class` (the model name), the `reads` / `writes` / `calls` / `raises `
521524/// effect annotations verbatim from [`ruff_spo_triplet::Function`] (OGAR-AS-IR
522525/// §3 test 2 — "each `ActionDef` declares what it reads, what it writes, what
523- /// side effects it has… pure-vs-effectful is a type, not a comment"). It
524- /// deliberately does **not**:
526+ /// side effects it has… pure-vs-effectful is a type, not a comment"), and
527+ /// (SPEC-ATC2-OGAR Arm A) `kausal` — but ONLY when the method is a
528+ /// [`Model::fields`] compute target (`Field::emitted_by == Some(f.name)`),
529+ /// in which case `kausal` becomes `Some(KausalSpec::Depends { paths })`
530+ /// sourced from that field's `Field::depends_on` (the `@api.depends(...)`
531+ /// fact). It deliberately does **not**:
525532///
526533/// - set any execution policy (the vocab [`ActionDef`] has no `exec` slot;
527534/// backend routing is consumer-private — see the OP-arm plan §5.2),
@@ -530,16 +537,28 @@ pub fn project_canonical_roles(class: &Class) -> std::collections::HashSet<&'sta
530537/// - populate `kausal` from [`ruff_spo_triplet::Function`]`::reads` — a
531538/// plain Rails method reading a field is **not** a reactive
532539/// `@api.depends`-style trigger, so claiming one would leak method-body
533- /// description into causal semantics. `reads` (and `writes` / `calls`) now
534- /// ride `ActionDef` as **effect annotations** (see above) — that is a
535- /// distinct, weaker claim than a `KausalSpec::Depends` reactive trigger,
536- /// which stays `None` here. `raises` / `traverses` have no `ActionDef`
537- /// slot yet and still ride the narrow / SPO arm as triples only.
540+ /// description into causal semantics. `reads` (and `writes` / `calls`) ride
541+ /// `ActionDef` as **effect annotations** (see above) — that is a distinct,
542+ /// weaker claim than a `KausalSpec::Depends` reactive trigger, which stays
543+ /// `None` for any method that isn't a known compute target. `traverses`
544+ /// still has no `ActionDef` slot and rides the narrow / SPO arm as triples
545+ /// only.
538546///
539547/// The result is registry-ready: guard / RBAC / `exec` enrichment happens
540548/// downstream at registration, not in the producer.
541549#[ must_use]
542550pub fn lift_actions ( model : & Model ) -> Vec < ActionDef > {
551+ // SPEC-ATC2-OGAR Arm A: compute-method name -> its field's @api.depends
552+ // paths. Built from `Model::fields` (Field::emitted_by / depends_on),
553+ // NOT from `reads` — the only provenance-honest source of a reactive
554+ // `KausalSpec::Depends` trigger (see the doc comment above).
555+ let depends_by_method: HashMap < & str , & Vec < String > > = model
556+ . fields
557+ . iter ( )
558+ . filter_map ( |field| field. emitted_by . as_deref ( ) . map ( |m| ( m, & field. depends_on ) ) )
559+ . filter ( |( _, paths) | !paths. is_empty ( ) )
560+ . collect ( ) ;
561+
543562 // Public actions AND non-public helpers (AT-CARRY-1b, review on #164):
544563 // since ruff #45 the Ruby walker splits private/protected defs into
545564 // `Model::helpers` — and Rails lifecycle hook targets are conventionally
@@ -562,6 +581,10 @@ pub fn lift_actions(model: &Model) -> Vec<ActionDef> {
562581 a. reads = f. reads . clone ( ) ;
563582 a. writes = f. writes . clone ( ) ;
564583 a. calls = f. calls . clone ( ) ;
584+ a. raises = f. raises . clone ( ) ;
585+ if let Some ( paths) = depends_by_method. get ( f. name . as_str ( ) ) {
586+ a. kausal = Some ( KausalSpec :: depends ( ( * paths) . clone ( ) ) ) ;
587+ }
565588 a
566589 } )
567590 . collect ( )
@@ -1807,6 +1830,78 @@ mod tests {
18071830 assert ! ( acts[ 0 ] . calls. is_empty( ) ) ;
18081831 }
18091832
1833+ /// SPEC-ATC2-OGAR Arm A: a method that IS a `Model::fields` compute
1834+ /// target (`Field::emitted_by == Some(method_name)`) gets its `kausal`
1835+ /// populated from that field's `@api.depends` paths
1836+ /// (`Field::depends_on`) — the only provenance-honest source for a
1837+ /// reactive `KausalSpec::Depends` trigger.
1838+ #[ test]
1839+ fn lift_actions_depends_field_yields_kausal_depends ( ) {
1840+ let mut m = Model :: new ( "SaleOrder" ) ;
1841+ m. functions . push ( Function {
1842+ name : "_compute_total" . to_string ( ) ,
1843+ ..Default :: default ( )
1844+ } ) ;
1845+ m. fields . push ( Field {
1846+ name : "amount_total" . to_string ( ) ,
1847+ emitted_by : Some ( "_compute_total" . to_string ( ) ) ,
1848+ depends_on : vec ! [ "qty" . to_string( ) , "price" . to_string( ) ] ,
1849+ ..Default :: default ( )
1850+ } ) ;
1851+ let acts = lift_actions ( & m) ;
1852+ assert_eq ! ( acts. len( ) , 1 ) ;
1853+ assert_eq ! (
1854+ acts[ 0 ] . kausal,
1855+ Some ( KausalSpec :: depends( vec![ "qty" . to_string( ) , "price" . to_string( ) ] ) ) ,
1856+ ) ;
1857+ }
1858+
1859+ /// SPEC-ATC2-OGAR Arm A regression (sharpens `lift_actions_is_facts_only`):
1860+ /// a method that merely READS fields, without being a compute target
1861+ /// (`Field::emitted_by`), must NOT get a fabricated `kausal` — `reads`
1862+ /// is an effect annotation, never a causal-dependency source
1863+ /// (Provenance-Ehrlichkeit).
1864+ #[ test]
1865+ fn lift_actions_plain_read_still_no_kausal ( ) {
1866+ let mut m = Model :: new ( "SaleOrder" ) ;
1867+ m. functions . push ( Function {
1868+ name : "log_access" . to_string ( ) ,
1869+ reads : vec ! [ "amount_total" . to_string( ) ] ,
1870+ ..Default :: default ( )
1871+ } ) ;
1872+ // `amount_total` exists as a field but is NOT computed by
1873+ // `log_access` (no `emitted_by` link to this method), so the read
1874+ // must stay a plain effect annotation.
1875+ m. fields . push ( Field {
1876+ name : "amount_total" . to_string ( ) ,
1877+ emitted_by : Some ( "_compute_total" . to_string ( ) ) ,
1878+ depends_on : vec ! [ "qty" . to_string( ) ] ,
1879+ ..Default :: default ( )
1880+ } ) ;
1881+ let acts = lift_actions ( & m) ;
1882+ assert_eq ! ( acts. len( ) , 1 ) ;
1883+ assert_eq ! ( acts[ 0 ] . reads, vec![ "amount_total" . to_string( ) ] ) ;
1884+ assert ! (
1885+ acts[ 0 ] . kausal. is_none( ) ,
1886+ "a plain field read must not fabricate a KausalSpec::Depends"
1887+ ) ;
1888+ }
1889+
1890+ /// SPEC-ATC2-OGAR Arm C: `Function::raises` (already populated by ruff,
1891+ /// core-7) now rides the new additive `ActionDef::raises` slot.
1892+ #[ test]
1893+ fn lift_actions_raises_carried_on_actiondef ( ) {
1894+ let mut m = Model :: new ( "SaleOrder" ) ;
1895+ m. functions . push ( Function {
1896+ name : "action_confirm" . to_string ( ) ,
1897+ raises : vec ! [ "UserError" . to_string( ) ] ,
1898+ ..Default :: default ( )
1899+ } ) ;
1900+ let acts = lift_actions ( & m) ;
1901+ assert_eq ! ( acts. len( ) , 1 ) ;
1902+ assert_eq ! ( acts[ 0 ] . raises, vec![ "UserError" . to_string( ) ] ) ;
1903+ }
1904+
18101905 /// Regression: adding the effect-annotation fields must not disturb the
18111906 /// pre-existing identity / predicate / object_class shape — same
18121907 /// assertions as `lift_actions_predicate_object_class_and_identity`,
0 commit comments