diff --git a/crates/ogar-emitter/src/lib.rs b/crates/ogar-emitter/src/lib.rs index 9d5a34a..f6e3e7f 100644 --- a/crates/ogar-emitter/src/lib.rs +++ b/crates/ogar-emitter/src/lib.rs @@ -1374,6 +1374,40 @@ mod tests { assert!(t.iter().any(|t| t.predicate == "ogar:kausalKind" && t.object == "ogar:External")); } + #[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()]), + ] { + 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 == "ogar:Unknown"), + "interim: unwired Arm B variants label as ogar:Unknown" + ); + // 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)" + ); + } + } + #[test] fn action_state_lifecycle_serialization() { for (state, expected) in [ diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 8a5943d..6e0bfa4 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -722,6 +722,18 @@ impl KausalSpec { pub fn depends(paths: Vec) -> Self { Self::Depends { paths } } + + /// Convenience: build a Constrains spec (`@api.constrains` targets). + #[must_use] + pub fn constrains(paths: Vec) -> Self { + Self::Constrains { paths } + } + + /// Convenience: build an Onchange spec (`@api.onchange` targets). + #[must_use] + pub fn onchange(paths: Vec) -> Self { + Self::Onchange { paths } + } } /// The four canonical Active Record relation kinds. Cross-ORM mapping: diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index 58b0775..d9eb591 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -1083,3 +1083,45 @@ isolation. The map's job is to keep them visible. 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. + +- **D-ATC2-KAUSAL-RUFF-GATED** — 2026-07-06 `[G]`: AT-CARRY-2 arms B+D + landed (OGAR #169) after ruff #49 put `Function::{constrains,onchange}` + + `Field::stored` on ruff main. Arm B: `KausalSpec::{Constrains,Onchange}` + populated in `lift_actions` ONLY when `kausal.is_none()` (SPEC §3b) — so + Depends (Arm A) always wins, Constrains beats Onchange; sourced purely + from the decorator fields, never `reads`/`writes` (regression + `lift_actions_depends_arm_a_regression_unaffected_by_arm_b`). Arm D: + `ComputedField.stored = field.stored.unwrap_or(false)` at both projection + sites (§5: Odoo's not-stored default). Post-merge verification: OGAR + workspace green against ruff main `9ef26c1` — `cargo build --workspace` + clean incl. `ogar-from-rails` (the float-on-main risk), `cargo test + --workspace` 479/0. Opus adversarial review: CLEAN impl, one P2 below. + - **HONEST CORRECTION to the D-ATC2-KAUSAL-AUTARK fuse rationale.** That + entry (and the B-arm commit message) claimed the exhaustive + `kausal_spec_match_is_exhaustive` guard makes *consumers* "loud break + instead of silently defaulting" on new variants. That is TRUE only + intra-crate. `KausalSpec` is `#[non_exhaustive]`, so every cross-crate + `match` is FORCED to carry a wildcard — the fuse cannot protect any + consumer outside ogar-vocab. The claim was overstated. + - **DEBT (P2, spec-deferred per §6): the TTL emitter silently mislabels + Arm B.** `ogar-emitter::kausal_triples` has no Constrains/Onchange arm; + both fall through `_ => ogar:Unknown` (lib.rs:779) and their field paths + are DROPPED at TTL emission. The IR struct is correct (consumers that + read `ActionDef.kausal` directly — e.g. the odoo-rs AT-CONSUME parity + pin — are unaffected); only the TTL projection is lossy. §6 defers the + downstream-consumer wiring, so this is not a spec violation, but it is + actively-wrong (not merely missing) TTL. (NB: SPEC-ATC2-OGAR §6 line 219 + still names a "SurrealQL-Adapter" as a deferred consumer — that naming is + STALE. Per the operator ruling recorded above (2026-07-05: "Odoo's spog + lives now in V3 substrate in lance-graph <>OGAR, not surrealdb AST"), the + forward substrate is the OGAR-v3 / lance-graph path; the SurrealQL-AST + adapter is deprecated. The live debt is the `ogar-emitter` TTL path, which + `ogar-adapter-ttl` and the v3 substrate consume — not SurrealQL.) + Converted from silent to + documented: characterization test + `kausal_constrains_onchange_currently_emit_unknown_pending_emitter_wiring` + PINS the interim `ogar:Unknown` + dropped-paths behaviour so it flips + LOUDLY when §6 lands (defining `ogar:Constrains`/`ogar:Onchange` kinds + + 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).