From 1aad095b846c3a1a37ba6f3b18a107b8c277015c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 21:10:05 +0000 Subject: [PATCH 1/3] ogar-vocab: additive ActionDef.raises slot (SPEC-ATC2-OGAR Arm C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Function::raises already exists in ruff_spo_triplet (core-7) but had no ActionDef slot to land in. Add `pub raises: Vec` right after the existing `calls` effect annotation, mirroring its shape exactly - additive, Default-derived, no existing `ActionDef::new(...)`/`..Default:: default()` construction site needs to change. Also add the exhaustive-match governance test for KausalSpec (`kausal_spec_match_is_exhaustive`) required by SPEC-ATC2-OGAR §7: a wildcard-free match over all five current variants that will fail to compile the moment Arm B (Constrains/Onchange) lands without updating this match - the COUNT_FUSE-style loud break the doctrine asks for instead of leaning on #[non_exhaustive] alone. --- crates/ogar-vocab/src/lib.rs | 57 ++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 5ff524b..6561f83 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -405,6 +405,11 @@ pub struct ActionDef { /// Lifecycle-mutator dispatch CALLS (`Function::calls`, `.`). /// Effect annotation for call-graph analysis; no body is captured. pub calls: Vec, + /// Exception/error type names this action may RAISE (`ruff_spo_triplet:: + /// Function::raises`, e.g. `UserError`, `ValidationError`). Effect + /// annotation against the `exc:` namespace; Authoritative (the `raise` + /// statement names the type). Name-level only — no message/condition. + pub raises: Vec, // ── Rubicon statem carriers (OGAR-AST-CONTRACT §6) ── // The three semantics that don't survive Action-flattening; each lowers @@ -6005,6 +6010,58 @@ mod tests { assert_eq!(a.on_enter.as_ref().unwrap().to_value, "sale"); } + #[test] + fn action_def_raises_slot_is_additive_and_defaults_empty() { + // SPEC-ATC2-OGAR Arm C: `raises` is a new additive slot on + // `ActionDef`, mirroring the existing `calls` effect annotation. + // Existing `..Default::default()` construction sites (and + // `ActionDef::new`) must keep compiling with an empty `raises`. + let a = ActionDef::default(); + assert!(a.raises.is_empty()); + let mut b = ActionDef::new("id", "predicate", "object_class"); + assert!(b.raises.is_empty()); + b.raises = vec!["UserError".to_string()]; + assert_eq!(b.raises, vec!["UserError".to_string()]); + } + + #[test] + fn kausal_spec_match_is_exhaustive() { + // SPEC-ATC2-OGAR §7 governance test: an exhaustive match (no + // wildcard arm) over every `KausalSpec` variant. `KausalSpec` is + // `#[non_exhaustive]` for DOWNSTREAM crates only — within the + // defining crate a wildcard-free match is still exhaustiveness + // checked by rustc, so this test fails to COMPILE the moment a + // new variant (e.g. `Constrains` / `Onchange` from Arm B) is added + // without a matching arm here. That loud compile break is the + // COUNT_FUSE substitute the doctrine asks for in place of leaning + // on `#[non_exhaustive]` alone. + let specs = vec![ + KausalSpec::StateGuard { + guard_field: "state".into(), + guard_values: vec!["draft".into()], + }, + KausalSpec::LifecycleTrigger { + event: "before_save".into(), + }, + KausalSpec::Depends { + paths: vec!["a.b".into()], + }, + KausalSpec::ContextDepends { + keys: vec!["lang".into()], + }, + KausalSpec::External, + ]; + for spec in specs { + match spec { + KausalSpec::StateGuard { .. } => {} + KausalSpec::LifecycleTrigger { .. } => {} + KausalSpec::Depends { .. } => {} + KausalSpec::ContextDepends { .. } => {} + KausalSpec::External => {} + } + } + } + // ── all_promoted_classes() — enumerator pinned to class_ids::ALL ── #[test] From 5c74e0ba3a97f20612363cf3eaf8678ab91b009d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 21:10:16 +0000 Subject: [PATCH 2/3] ogar-from-ruff: wire Depends kausal + raises into lift_actions (Arm A + C) Arm A: build a compute-method -> @api.depends-paths index from Model::fields (Field::emitted_by / Field::depends_on - both already flow through ruff, no ruff change needed) and populate ActionDef::kausal = Some(KausalSpec::depends(paths)) for exactly the methods that are a field's compute target. Provenance-honest: `reads` never fabricates a `kausal` trigger, only the real @api.depends fact does; a method with no matching Field::emitted_by keeps kausal == None (pins lift_actions_plain_read_still_no_kausal). Arm C: a.raises = f.raises.clone() - straight verbatim carry now that ActionDef has the slot. Updates the two stale doc comments that said kausal/raises had no path onto ActionDef. New tests: lift_actions_depends_field_yields_kausal_ depends, lift_actions_plain_read_still_no_kausal, lift_actions_raises_carried_on_actiondef. Existing lift_actions_is_facts_ only / lift_actions_carries_read_write_call_effect_facts stay green unchanged (their fixtures declare no compute fields). Arms B (Constrains/Onchange) and D (ComputedField.stored) are out of scope - they need ruff_spo_triplet::Function::constrains/onchange and Field::stored, which do not exist yet on ruff main (confirmed by reading ruff_spo_triplet's Function/Field structs in the resolved git dependency checkout). --- crates/ogar-from-ruff/src/lib.rs | 121 +++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 13 deletions(-) diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index 58bd240..481c42a 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -45,10 +45,11 @@ //! - `Model::functions` IS now lifted — by [`lift_actions`] (the DO-arm) //! to a standalone `Vec`, not onto `Class` (the OGAR `Class` //! is the THING/THINK shape; actions register on the DO-arm -//! separately). Each method's `reads` / `raises` / `traverses` edges -//! stay on the narrow / SPO arm as triples — `lift_actions` does not -//! duplicate them, nor claim a reactive dependency plain Rails methods -//! don't declare. +//! separately). Each method's `reads` / `writes` / `calls` / `raises` +//! edges ride `ActionDef` as effect annotations; `traverses` still has +//! no `ActionDef` slot and stays on the narrow / SPO arm as triples only +//! — `lift_actions` does not duplicate it, nor claim a reactive +//! dependency plain Rails methods don't declare. //! - `Model::delegations`, `Model::dsl_calls`, `Model::gem_dsl`, //! `Model::dynamic_methods`, `Model::refinements` — ruff's //! long-tail. OGAR doesn't model them yet; can land as @@ -70,9 +71,11 @@ pub mod emit; pub mod mint; pub mod sqlalchemy; // WS-G-D +use std::collections::HashMap; + use ogar_vocab::{ canonical_concept, ActionDef, Association, AssociationKind, Attribute, Callback, Class, - ComputedField, EnumDecl, EnumSource, Inheritance, Language, Scope, Validation, + ComputedField, EnumDecl, EnumSource, Inheritance, KausalSpec, Language, Scope, Validation, }; use ruff_spo_triplet::{ AssocDecl, AssocKind, AttrDecl, AttrKind, Callback as RuffCallback, ConcernKind, Model, @@ -517,11 +520,15 @@ pub fn project_canonical_roles(class: &Class) -> std::collections::HashSet<&'sta /// /// This is a **facts-only** lift: it sets the action's `identity`, /// `predicate` (the method name — already snake_case on the Rails side), -/// `object_class` (the model name), and the `reads` / `writes` / `calls` +/// `object_class` (the model name), the `reads` / `writes` / `calls` / `raises` /// effect annotations verbatim from [`ruff_spo_triplet::Function`] (OGAR-AS-IR /// §3 test 2 — "each `ActionDef` declares what it reads, what it writes, what -/// side effects it has… pure-vs-effectful is a type, not a comment"). It -/// deliberately does **not**: +/// side effects it has… pure-vs-effectful is a type, not a comment"), and +/// (SPEC-ATC2-OGAR Arm A) `kausal` — but ONLY when the method is a +/// [`Model::fields`] compute target (`Field::emitted_by == Some(f.name)`), +/// in which case `kausal` becomes `Some(KausalSpec::Depends { paths })` +/// sourced from that field's `Field::depends_on` (the `@api.depends(...)` +/// fact). It deliberately does **not**: /// /// - set any execution policy (the vocab [`ActionDef`] has no `exec` slot; /// 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 /// - populate `kausal` from [`ruff_spo_triplet::Function`]`::reads` — a /// plain Rails method reading a field is **not** a reactive /// `@api.depends`-style trigger, so claiming one would leak method-body -/// description into causal semantics. `reads` (and `writes` / `calls`) now -/// ride `ActionDef` as **effect annotations** (see above) — that is a -/// distinct, weaker claim than a `KausalSpec::Depends` reactive trigger, -/// which stays `None` here. `raises` / `traverses` have no `ActionDef` -/// slot yet and still ride the narrow / SPO arm as triples only. +/// description into causal semantics. `reads` (and `writes` / `calls`) ride +/// `ActionDef` as **effect annotations** (see above) — that is a distinct, +/// weaker claim than a `KausalSpec::Depends` reactive trigger, which stays +/// `None` for any method that isn't a known compute target. `traverses` +/// still has no `ActionDef` slot and rides the narrow / SPO arm as triples +/// only. /// /// The result is registry-ready: guard / RBAC / `exec` enrichment happens /// downstream at registration, not in the producer. #[must_use] pub fn lift_actions(model: &Model) -> Vec { + // SPEC-ATC2-OGAR Arm A: compute-method name -> its field's @api.depends + // paths. Built from `Model::fields` (Field::emitted_by / depends_on), + // NOT from `reads` — the only provenance-honest source of a reactive + // `KausalSpec::Depends` trigger (see the doc comment above). + let depends_by_method: HashMap<&str, &Vec> = model + .fields + .iter() + .filter_map(|field| field.emitted_by.as_deref().map(|m| (m, &field.depends_on))) + .filter(|(_, paths)| !paths.is_empty()) + .collect(); + // Public actions AND non-public helpers (AT-CARRY-1b, review on #164): // since ruff #45 the Ruby walker splits private/protected defs into // `Model::helpers` — and Rails lifecycle hook targets are conventionally @@ -562,6 +581,10 @@ pub fn lift_actions(model: &Model) -> Vec { a.reads = f.reads.clone(); a.writes = f.writes.clone(); a.calls = f.calls.clone(); + a.raises = f.raises.clone(); + if let Some(paths) = depends_by_method.get(f.name.as_str()) { + a.kausal = Some(KausalSpec::depends((*paths).clone())); + } a }) .collect() @@ -1807,6 +1830,78 @@ mod tests { assert!(acts[0].calls.is_empty()); } + /// SPEC-ATC2-OGAR Arm A: a method that IS a `Model::fields` compute + /// target (`Field::emitted_by == Some(method_name)`) gets its `kausal` + /// populated from that field's `@api.depends` paths + /// (`Field::depends_on`) — the only provenance-honest source for a + /// reactive `KausalSpec::Depends` trigger. + #[test] + fn lift_actions_depends_field_yields_kausal_depends() { + let mut m = Model::new("SaleOrder"); + m.functions.push(Function { + name: "_compute_total".to_string(), + ..Default::default() + }); + m.fields.push(Field { + name: "amount_total".to_string(), + emitted_by: Some("_compute_total".to_string()), + depends_on: vec!["qty".to_string(), "price".to_string()], + ..Default::default() + }); + let acts = lift_actions(&m); + assert_eq!(acts.len(), 1); + assert_eq!( + acts[0].kausal, + Some(KausalSpec::depends(vec!["qty".to_string(), "price".to_string()])), + ); + } + + /// SPEC-ATC2-OGAR Arm A regression (sharpens `lift_actions_is_facts_only`): + /// a method that merely READS fields, without being a compute target + /// (`Field::emitted_by`), must NOT get a fabricated `kausal` — `reads` + /// is an effect annotation, never a causal-dependency source + /// (Provenance-Ehrlichkeit). + #[test] + fn lift_actions_plain_read_still_no_kausal() { + let mut m = Model::new("SaleOrder"); + m.functions.push(Function { + name: "log_access".to_string(), + reads: vec!["amount_total".to_string()], + ..Default::default() + }); + // `amount_total` exists as a field but is NOT computed by + // `log_access` (no `emitted_by` link to this method), so the read + // must stay a plain effect annotation. + m.fields.push(Field { + name: "amount_total".to_string(), + emitted_by: Some("_compute_total".to_string()), + depends_on: vec!["qty".to_string()], + ..Default::default() + }); + let acts = lift_actions(&m); + assert_eq!(acts.len(), 1); + assert_eq!(acts[0].reads, vec!["amount_total".to_string()]); + assert!( + acts[0].kausal.is_none(), + "a plain field read must not fabricate a KausalSpec::Depends" + ); + } + + /// SPEC-ATC2-OGAR Arm C: `Function::raises` (already populated by ruff, + /// core-7) now rides the new additive `ActionDef::raises` slot. + #[test] + fn lift_actions_raises_carried_on_actiondef() { + let mut m = Model::new("SaleOrder"); + m.functions.push(Function { + name: "action_confirm".to_string(), + raises: vec!["UserError".to_string()], + ..Default::default() + }); + let acts = lift_actions(&m); + assert_eq!(acts.len(), 1); + assert_eq!(acts[0].raises, vec!["UserError".to_string()]); + } + /// Regression: adding the effect-annotation fields must not disturb the /// pre-existing identity / predicate / object_class shape — same /// assertions as `lift_actions_predicate_object_class_and_identity`, From 5d22120ed3ee4e991b09e46c35d602ee7b67c289 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 21:20:26 +0000 Subject: [PATCH 3/3] =?UTF-8?q?ogar:=20review=20fixes=20(P2)=20+=20ledger?= =?UTF-8?q?=20=E2=80=94=20serde(default)=20on=20raises,=20missing-key=20ro?= =?UTF-8?q?und-trip=20test,=20D-ATC2-KAUSAL-AUTARK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActionDef.raises now carries #[cfg_attr(feature = "serde", serde(default))] per the vocab's own additive-field convention; pre-raises JSON deserializes (new feature-gated test, serde_json dev-dep added). - DISCOVERY-MAP: D-ATC2-KAUSAL-AUTARK appended (arms A+C autark, B+D gated on ruff #49, last-wins index assumption named). --- crates/ogar-vocab/Cargo.toml | 3 +++ crates/ogar-vocab/src/lib.rs | 15 +++++++++++++++ docs/DISCOVERY-MAP.md | 22 ++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/crates/ogar-vocab/Cargo.toml b/crates/ogar-vocab/Cargo.toml index 3d22ca4..16274f0 100644 --- a/crates/ogar-vocab/Cargo.toml +++ b/crates/ogar-vocab/Cargo.toml @@ -14,3 +14,6 @@ serde = ["dep:serde"] [dependencies] serde = { workspace = true, optional = true } + +[dev-dependencies] +serde_json = "1.0" diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 6561f83..db5024a 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -409,6 +409,8 @@ pub struct ActionDef { /// Function::raises`, e.g. `UserError`, `ValidationError`). Effect /// annotation against the `exc:` namespace; Authoritative (the `raise` /// statement names the type). Name-level only — no message/condition. + /// `serde(default)`: additive field, pre-`raises` material deserializes. + #[cfg_attr(feature = "serde", serde(default))] pub raises: Vec, // ── Rubicon statem carriers (OGAR-AST-CONTRACT §6) ── @@ -6024,6 +6026,19 @@ mod tests { assert_eq!(b.raises, vec!["UserError".to_string()]); } + /// `serde(default)` guard: pre-`raises` JSON (no `raises` key) must + /// still deserialize — the additive-field convention the vocab uses + /// for every migration-cycle field. + #[cfg(feature = "serde")] + #[test] + fn action_def_raises_missing_key_deserializes() { + let a = ActionDef::new("id", "predicate", "object_class"); + let mut json: serde_json::Value = serde_json::to_value(&a).unwrap(); + json.as_object_mut().unwrap().remove("raises"); + let back: ActionDef = serde_json::from_value(json).unwrap(); + assert!(back.raises.is_empty()); + } + #[test] fn kausal_spec_match_is_exhaustive() { // SPEC-ATC2-OGAR §7 governance test: an exhaustive match (no diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index af0c1bb..ae0e973 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -1061,3 +1061,25 @@ isolation. The map's job is to keep them visible. lockstep left as a separate, named follow-up. - **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). + +- **D-ATC2-KAUSAL-AUTARK** — 2026-07-06 `[G]`: AT-CARRY-2 arms A+C landed + self-contained (no ruff prerequisite). Arm A: `lift_actions` populates + `kausal = KausalSpec::Depends` from the `Field::emitted_by → + Field::depends_on` index ONLY — never fabricated from `reads` + (provenance honesty, regression `lift_actions_plain_read_still_no_kausal` + proves a read of a computed field does not leak kausal onto the reader). + Arm C: additive `ActionDef.raises` slot (`Function::raises`, + Authoritative), `serde(default)` per the vocab's additive-field + convention + missing-key round-trip test. The odoo-rs triangulation + claimed the whole KausalSpec chain was ruff-gated; primary-source + verification narrowed it: only arms B (constrains/onchange variants) + + D (`computed.stored`) need ruff — landing there as ruff #49, OGAR + follow-up wires them AFTER ruff main carries the fields (float-on-main, + D-NEVER-PIN-BUMP; the interim loud break is the design). Guard: + `kausal_spec_match_is_exhaustive` — a wildcard-free intra-crate match + that fails to COMPILE when arm B adds variants without updating + consumers. Named assumption (untested, spec-conform): two fields + sharing one `emitted_by` compute method resolve last-wins in the + index — safe under Odoo semantics (`@api.depends` is method-level, + co-computed fields carry identical `depends_on`), revisit if a + frontend ever emits divergent `depends_on` per co-computed field.