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
25 changes: 22 additions & 3 deletions crates/ogar-emitter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -675,8 +675,14 @@ impl TripleEmitter {
}
// Rubicon statem carriers (OGAR-AST-CONTRACT §6). Emitted only when
// present so non-statem ActionDefs stay clean.
// ogar:onEnter ranges ogar:EnterEffect (vocab/ogar.ttl, #10/#13); emit the
// typed shape as one link triple + the two leaf triples (ogar:enterField,
// ogar:enterToValue) so consumers reassemble the EnterEffect structurally.
if let Some(ref e) = def.on_enter {
triples.push(Triple::new(&id, "ogar:onEnter", e.clone()));
let node = format!("{}::on_enter", id);
triples.push(Triple::new(&id, "ogar:onEnter", node.clone()));
triples.push(Triple::new(&node, "ogar:enterField", e.field.clone()));
triples.push(Triple::new(&node, "ogar:enterToValue", e.to_value.clone()));
}
if let Some(p) = def.guard_failure_policy {
triples.push(Triple::new(
Expand Down Expand Up @@ -1304,11 +1310,24 @@ mod tests {
"action_confirm",
"ogit-erp/sale.order",
);
def.on_enter = Some("draft-to-sale".into());
def.on_enter = Some(ogar_vocab::EnterEffect::transition("state", "sale"));
def.guard_failure_policy = Some(ogar_vocab::GuardFailurePolicy::Postponable);
def.state_timeout_millis = Some(30_000);
let t = TripleEmitter::emit_action_def(&def);
assert!(t.iter().any(|x| x.predicate == "ogar:onEnter" && x.object == "draft-to-sale"));
// ogar:onEnter is now a link to a per-def node carrying the typed EnterEffect
// (ogar:enterField + ogar:enterToValue), per vocab/ogar.ttl § Rubicon terms.
let link = t
.iter()
.find(|x| x.predicate == "ogar:onEnter")
.expect("ogar:onEnter link triple emitted");
let node = link.object.clone();
assert!(node.ends_with("::on_enter"), "onEnter node should be derived from def id, got: {node}");
assert!(t
.iter()
.any(|x| x.subject == node && x.predicate == "ogar:enterField" && x.object == "state"));
assert!(t
.iter()
.any(|x| x.subject == node && x.predicate == "ogar:enterToValue" && x.object == "sale"));
assert!(t
.iter()
.any(|x| x.predicate == "ogar:guardFailurePolicy" && x.object == "ogar:Postponable"));
Expand Down
64 changes: 33 additions & 31 deletions crates/ogar-vocab/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,31 +303,6 @@ impl Default for RecordSemantics {
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]

/// Typed entry effect — the structured representation of the state mutation
/// that fires on entering `Committed` (the Rubicon crossing). Replaces
/// free-form strings on [`ActionDef::on_enter`] so the codegen can apply the
/// transition structurally instead of string-parsing.
///
/// v1 carries the dominant lifecycle-FSM case (`field := to_value`). Complex
/// domain operations (e.g. chess `Move::Castle`) carry their payload on the
/// `ActionInvocation` and use `on_enter` only for the lifecycle-visible
/// transition (e.g. `side_to_move := Black`). Future tightening to typed
/// values (beyond string-encoded `to_value`) is a tracked follow-up.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct EnterEffect {
/// Field on `object_instance` being set.
pub field: String,
/// Value to set the field to (string-encoded; typed values noted as a follow-up).
pub to_value: String,
}

impl EnterEffect {
/// Convenience constructor for the common `field := value` case.
pub fn transition(field: impl Into<String>, to_value: impl Into<String>) -> Self {
Self { field: field.into(), to_value: to_value.into() }
}
}

pub struct ActionDef {
/// Stable identity for the action declaration (e.g.
Expand Down Expand Up @@ -373,6 +348,33 @@ pub struct ActionDef {
pub state_timeout_millis: Option<i64>,
}

/// Typed entry effect — the structured representation of the state mutation
/// that fires on entering `Committed` (the Rubicon crossing). Replaces
/// free-form strings on [`ActionDef::on_enter`] so the codegen can apply the
/// transition structurally instead of string-parsing.
///
/// v1 carries the dominant lifecycle-FSM case (`field := to_value`). Complex
/// domain operations (e.g. chess `Move::Castle`) carry their payload on the
/// `ActionInvocation` and use `on_enter` only for the lifecycle-visible
/// transition (e.g. `side_to_move := Black`). Future tightening to typed
/// values (beyond string-encoded `to_value`) is a tracked follow-up.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct EnterEffect {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore non_exhaustive on EnterEffect

Because EnterEffect is a public vocabulary struct and the module docs promise every public struct/enum is forward-compatible via #[non_exhaustive], leaving this type exhaustive lets downstream crates construct it with a literal and makes adding the planned typed value fields a semver-breaking change. Please put #[non_exhaustive] back on EnterEffect after moving it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the latest push. #[non_exhaustive] restored on EnterEffect, ordered after the derives to match ActionDef's convention in this crate. Tests still compile — #[non_exhaustive] only restricts external-crate construction; the in-crate assert_eq!(e, EnterEffect { ... }) literal is permitted within ogar-vocab itself. This makes the planned typed-value tightening of to_value (and any other future fields) a non-breaking change for downstream consumers.

/// Field on `object_instance` being set.
pub field: String,
/// Value to set the field to (string-encoded; typed values noted as a follow-up).
pub to_value: String,
}

impl EnterEffect {
/// Convenience constructor for the common `field := value` case.
pub fn transition(field: impl Into<String>, to_value: impl Into<String>) -> Self {
Self { field: field.into(), to_value: to_value.into() }
}
}

/// Disposition when a `KausalSpec::StateGuard` is not satisfied — the Modal
/// sub-property for the Rubicon statem lowering (OGAR-AST-CONTRACT §6).
/// `#[non_exhaustive]` per the vocabulary forward-compat convention.
Expand Down Expand Up @@ -1084,12 +1086,6 @@ mod tests {
assert!(block_form.body_source.is_some());
assert!(block_form.target_method.is_none());
}
}

impl Default for Language {
fn default() -> Self {
Self::Ruby
}

#[test]
fn enter_effect_is_typed_and_constructible() {
Expand All @@ -1113,3 +1109,9 @@ impl Default for Language {
assert_eq!(a.on_enter.as_ref().unwrap().to_value, "sale");
}
}

impl Default for Language {
fn default() -> Self {
Self::Ruby
}
}