Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions crates/ogar-emitter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
12 changes: 12 additions & 0 deletions crates/ogar-vocab/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,18 @@ impl KausalSpec {
pub fn depends(paths: Vec<String>) -> Self {
Self::Depends { paths }
}

/// Convenience: build a Constrains spec (`@api.constrains` targets).
#[must_use]
pub fn constrains(paths: Vec<String>) -> Self {
Self::Constrains { paths }
}

/// Convenience: build an Onchange spec (`@api.onchange` targets).
#[must_use]
pub fn onchange(paths: Vec<String>) -> Self {
Self::Onchange { paths }
}
}

/// The four canonical Active Record relation kinds. Cross-ORM mapping:
Expand Down
42 changes: 42 additions & 0 deletions docs/DISCOVERY-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Loading