Skip to content

Commit f8420b9

Browse files
authored
Merge pull request #168 from AdaWorldAPI/claude/atc2-kausal-autark
lift_actions: kausal (Depends) + ActionDef.raises — AT-CARRY-2 autarke Arme A+C
2 parents acb36db + 5d22120 commit f8420b9

4 files changed

Lines changed: 205 additions & 13 deletions

File tree

crates/ogar-from-ruff/src/lib.rs

Lines changed: 108 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,11 @@
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;
7071
pub mod mint;
7172
pub mod sqlalchemy; // WS-G-D
7273

74+
use std::collections::HashMap;
75+
7376
use 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
};
7780
use 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]
542550
pub 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`,

crates/ogar-vocab/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ serde = ["dep:serde"]
1414

1515
[dependencies]
1616
serde = { workspace = true, optional = true }
17+
18+
[dev-dependencies]
19+
serde_json = "1.0"

crates/ogar-vocab/src/lib.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,13 @@ pub struct ActionDef {
405405
/// Lifecycle-mutator dispatch CALLS (`Function::calls`, `<receiver>.<method>`).
406406
/// Effect annotation for call-graph analysis; no body is captured.
407407
pub calls: Vec<String>,
408+
/// Exception/error type names this action may RAISE (`ruff_spo_triplet::
409+
/// Function::raises`, e.g. `UserError`, `ValidationError`). Effect
410+
/// annotation against the `exc:` namespace; Authoritative (the `raise`
411+
/// statement names the type). Name-level only — no message/condition.
412+
/// `serde(default)`: additive field, pre-`raises` material deserializes.
413+
#[cfg_attr(feature = "serde", serde(default))]
414+
pub raises: Vec<String>,
408415

409416
// ── Rubicon statem carriers (OGAR-AST-CONTRACT §6) ──
410417
// The three semantics that don't survive Action-flattening; each lowers
@@ -6005,6 +6012,71 @@ mod tests {
60056012
assert_eq!(a.on_enter.as_ref().unwrap().to_value, "sale");
60066013
}
60076014

6015+
#[test]
6016+
fn action_def_raises_slot_is_additive_and_defaults_empty() {
6017+
// SPEC-ATC2-OGAR Arm C: `raises` is a new additive slot on
6018+
// `ActionDef`, mirroring the existing `calls` effect annotation.
6019+
// Existing `..Default::default()` construction sites (and
6020+
// `ActionDef::new`) must keep compiling with an empty `raises`.
6021+
let a = ActionDef::default();
6022+
assert!(a.raises.is_empty());
6023+
let mut b = ActionDef::new("id", "predicate", "object_class");
6024+
assert!(b.raises.is_empty());
6025+
b.raises = vec!["UserError".to_string()];
6026+
assert_eq!(b.raises, vec!["UserError".to_string()]);
6027+
}
6028+
6029+
/// `serde(default)` guard: pre-`raises` JSON (no `raises` key) must
6030+
/// still deserialize — the additive-field convention the vocab uses
6031+
/// for every migration-cycle field.
6032+
#[cfg(feature = "serde")]
6033+
#[test]
6034+
fn action_def_raises_missing_key_deserializes() {
6035+
let a = ActionDef::new("id", "predicate", "object_class");
6036+
let mut json: serde_json::Value = serde_json::to_value(&a).unwrap();
6037+
json.as_object_mut().unwrap().remove("raises");
6038+
let back: ActionDef = serde_json::from_value(json).unwrap();
6039+
assert!(back.raises.is_empty());
6040+
}
6041+
6042+
#[test]
6043+
fn kausal_spec_match_is_exhaustive() {
6044+
// SPEC-ATC2-OGAR §7 governance test: an exhaustive match (no
6045+
// wildcard arm) over every `KausalSpec` variant. `KausalSpec` is
6046+
// `#[non_exhaustive]` for DOWNSTREAM crates only — within the
6047+
// defining crate a wildcard-free match is still exhaustiveness
6048+
// checked by rustc, so this test fails to COMPILE the moment a
6049+
// new variant (e.g. `Constrains` / `Onchange` from Arm B) is added
6050+
// without a matching arm here. That loud compile break is the
6051+
// COUNT_FUSE substitute the doctrine asks for in place of leaning
6052+
// on `#[non_exhaustive]` alone.
6053+
let specs = vec![
6054+
KausalSpec::StateGuard {
6055+
guard_field: "state".into(),
6056+
guard_values: vec!["draft".into()],
6057+
},
6058+
KausalSpec::LifecycleTrigger {
6059+
event: "before_save".into(),
6060+
},
6061+
KausalSpec::Depends {
6062+
paths: vec!["a.b".into()],
6063+
},
6064+
KausalSpec::ContextDepends {
6065+
keys: vec!["lang".into()],
6066+
},
6067+
KausalSpec::External,
6068+
];
6069+
for spec in specs {
6070+
match spec {
6071+
KausalSpec::StateGuard { .. } => {}
6072+
KausalSpec::LifecycleTrigger { .. } => {}
6073+
KausalSpec::Depends { .. } => {}
6074+
KausalSpec::ContextDepends { .. } => {}
6075+
KausalSpec::External => {}
6076+
}
6077+
}
6078+
}
6079+
60086080
// ── all_promoted_classes() — enumerator pinned to class_ids::ALL ──
60096081

60106082
#[test]

docs/DISCOVERY-MAP.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,3 +1061,25 @@ isolation. The map's job is to keep them visible.
10611061
lockstep left as a separate, named follow-up.
10621062

10631063
- **D-NEVER-PIN-BUMP** — 2026-07-06 operator ruling `[G]`: *"wir machen NIEMALS pin bump."* Every cross-repo dep floats on `branch = "main"`; the drift protection is loud compile breaks + fuses + fix-forward (proven same-day: ruff #45 `guarded_writes` → OGAR #162 within minutes, twice). Consequently the #45-post-merge-audit nit "make `ruff_spo_triplet::{Function, Model}` `#[non_exhaustive]`" is **REJECTED by ruling** — it would erase the loud-break signal and tax every construction site org-wide forever (non_exhaustive forbids struct literals even with `..Default::default()` outside the defining crate, including ruff's own frontend crates). The standing pattern stays: downstream fixtures construct with `..Default::default()` (established in #162); a rustdoc note on `ruff_spo_triplet::{Function, Model}` documenting this construction convention is a named follow-up (no companion PR yet).
1064+
1065+
- **D-ATC2-KAUSAL-AUTARK** — 2026-07-06 `[G]`: AT-CARRY-2 arms A+C landed
1066+
self-contained (no ruff prerequisite). Arm A: `lift_actions` populates
1067+
`kausal = KausalSpec::Depends` from the `Field::emitted_by →
1068+
Field::depends_on` index ONLY — never fabricated from `reads`
1069+
(provenance honesty, regression `lift_actions_plain_read_still_no_kausal`
1070+
proves a read of a computed field does not leak kausal onto the reader).
1071+
Arm C: additive `ActionDef.raises` slot (`Function::raises`,
1072+
Authoritative), `serde(default)` per the vocab's additive-field
1073+
convention + missing-key round-trip test. The odoo-rs triangulation
1074+
claimed the whole KausalSpec chain was ruff-gated; primary-source
1075+
verification narrowed it: only arms B (constrains/onchange variants)
1076+
+ D (`computed.stored`) need ruff — landing there as ruff #49, OGAR
1077+
follow-up wires them AFTER ruff main carries the fields (float-on-main,
1078+
D-NEVER-PIN-BUMP; the interim loud break is the design). Guard:
1079+
`kausal_spec_match_is_exhaustive` — a wildcard-free intra-crate match
1080+
that fails to COMPILE when arm B adds variants without updating
1081+
consumers. Named assumption (untested, spec-conform): two fields
1082+
sharing one `emitted_by` compute method resolve last-wins in the
1083+
index — safe under Odoo semantics (`@api.depends` is method-level,
1084+
co-computed fields carry identical `depends_on`), revisit if a
1085+
frontend ever emits divergent `depends_on` per co-computed field.

0 commit comments

Comments
 (0)