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
18 changes: 18 additions & 0 deletions .claude/board/EPIPHANIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@

---

## 2026-06-30 — E-KEEP-AR-REMOVE-ORM — the consumer open-heart op KEEPS ActiveRecord and removes the ORM; OGAR is named after AR

**Status:** FRAMING (`[G]` for the name origin + the keep/remove split, operator-stated 2026-06-30; the convergence wiring it implies is `[H]`, gated per the OP assessment). Corrects a session inversion (mine) that read the consumer pivot as "castrate the hand-rolled Rails ActiveRecord betrayal" — **backwards**.

**The correction (operator):** *"We don't remove Active Record — that's exactly what we keep. We just do an open-heart operation to remove the ORM and wire pure AR on Rails. Hence OGAR (Open Graph Active Record) — the name was literally inspired by AR in OpenProject."*

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add the required Scope line to this epiphany

The .claude/AGENTS.md instructions for this log require each EPIPHANIES entry to have ## ... plus a **Status:** line, a **Scope:** line, body, and **Cross-ref:**; this new entry goes directly from Status to the correction text. Please add the missing Scope line so the append-only log keeps the documented structure future agents are told to rely on.

Useful? React with 👍 / 👎.


- **KEEP — the ActiveRecord pattern.** A class IS a record + behavior (associations / validations / callbacks / STI). This is the domain model and it is literally what OGAR's `Class`/`ClassView` represents. **OGAR = Open Graph Active Record**, named after AR in OpenProject. The ClassView IS the active record.
- **REMOVE — the ORM.** The hand-rolled persistence plumbing between AR and the DB. In `openproject-nexgen-rs` that is `op-db`'s hand-typed SQL repos + `FromRow` rows (9009 LOC) and `op-api`'s hand-mapped row→DTO (8592 LOC). An ORM re-implemented by hand IS the "betrayal" — **never AR itself.**
- **WIRE — "pure AR on Rails":** the AR domain model backed **directly by the OGAR graph** (classid-keyed node + ClassView = the record; persistence = the graph via OGAR emit), no ORM intermediary. The "open-heart operation": excise the ORM organ, keep the AR heart, re-plumb AR onto OGAR.

**Redmine-as-root.** OpenProject is a Redmine→ChiliProject→OpenProject fork; Redmine's cleaner ancestral AR (ERB **fieldview**) defines the canonical ClassView, OP (which accreted the hand-rolled ORM on top) converges onto it. **fieldview/erb → classview/askama** = the AR view layer becomes the OGAR ClassView rendered via askama — the render *skin* over the AR *substance* ("ice caking"). The hi/lo classid split already encodes it: lo-u16 = shared concept (Redmine≡OP, machine-checked 26/26), hi-u16 = per-app render skin.

**Guard (do not re-invert):** the operation is ORM-out / AR-in. Removing or re-deriving the ActiveRecord domain model is the inversion this entry exists to prevent. AR stays throughout; only the ORM and (later, gated) the SPO-corpus/native emit paths are subtractive — additive-then-subtractive, never the reverse.

**Cross-ref:** the 6-agent assessment (RESONATES/QUALIFIED) + the concrete additive increment (OGAR `compile_graph_ruby` ~15 LOC → OP `ogar-emit` Stage-B alongside the ORM → Redmine↔OP convergence pin) is captured in `openproject-nexgen-rs/.claude/handovers/2026-06-30-1200-op-redmine-ogar-convergence-assessment.md`. Doctrine home: `docs/OGAR-CONSUMER-BEST-PRACTICES.md` (classid-is-address / magic-at-resolution); `docs/OGAR-TRANSPILE-SUBSTRATE.md` (the 85/15 pull-in/pull-back). The OGAR-side keystone gap: a Rails `compile_graph_ruby` (today only `compile_graph_python` exists in `crates/ogar-from-ruff/src/mint.rs`).

---

## 2026-06-30 — E-ACCIDENTAL-IMPERATIVE — the hand-rolled residue = accidentally-imperative (AR verbs on AR targets, no declarative home) ∪ essentially-foreign; the body pass TRIAGES, it does not decompile

**Status:** CONJECTURE (`[H]` — the split is operator-reasoned + grounded in the Odoo↔Rails asymmetry; the ratio is unmeasured pending the body pass). Operator-directed 2026-06-30. Builds on E-FUNCTION-CATALOG. **Update 2026-06-30:** the F17 prerequisite SHIPPED — ruff now captures per-function `writes`/`calls` (AdaWorldAPI/ruff @ `claude/odoo-rs-transcode-lf8ya5`, commit `dd70588`): `ruff_spo_triplet::Function` gains `writes`+`calls`, the closed predicate vocab gains `writes_field` (Authoritative) + `calls` (Inferred), and the `ruff_ruby_spo` body walker populates both from the Rails AST (closed `AR_MUTATORS` set; `self.x=`→write, mutator dispatch→call, else read). The "NOT writes" blocker in the Falsifier below is **cleared**; F17 is RUNNABLE — the body-triage probe is the next deliverable. Ratio still unmeasured.
Expand Down
123 changes: 116 additions & 7 deletions crates/ogar-from-ruff/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,21 +323,43 @@ fn pascal_case(name: &str) -> String {
.collect()
}

/// `account_move` → `ACCOUNT_MOVE` (for the `*_CLASSID` const name).
/// `account_move` → `ACCOUNT_MOVE`; `WorkPackage` → `WORK_PACKAGE` (for the
/// `*_CLASSID` const name). Splits on `.`/`_` (Odoo's dotted/underscored
/// names) AND on a lower→upper case transition (Rails' bare PascalCase class
/// names carry no separator at all — `screaming_snake` must still find the
/// word boundary, or every Rails-sourced const collapses to one run-on word
/// like `WORKPACKAGE_CLASSID`). Does not split consecutive uppercase runs
/// (acronyms): `HTTPServer` → `HTTPSERVER` — no Rails/Odoo class name in the
/// corpus is acronym-prefixed, so this is a deliberately narrow rule, not a
/// general camelCase tokenizer.
fn screaming_snake(name: &str) -> String {
name.split(['.', '_'])
.filter(|seg| !seg.is_empty())
.map(str::to_uppercase)
.collect::<Vec<_>>()
.join("_")
let mut out = String::new();
let mut prev_lower = false;
for ch in name.chars() {
if ch == '.' || ch == '_' {
if !out.is_empty() && !out.ends_with('_') {
out.push('_');
}
prev_lower = false;
continue;
}
if ch.is_uppercase() && prev_lower {
out.push('_');
}
out.extend(ch.to_uppercase());
prev_lower = ch.is_lowercase();
}
out
}

#[cfg(test)]
mod tests {
use super::*;
use crate::mint::compile_graph_python;
use ogar_vocab::ports::OdooPort;
use ruff_spo_triplet::{Field, Function, Model, ModelGraph};
use ruff_spo_triplet::{
AssocDecl, AssocKind, AttrDecl, AttrKind, Field, Function, Model, ModelGraph,
};

fn account_move_graph() -> ModelGraph {
let mut m = Model::new("account_move");
Expand Down Expand Up @@ -383,6 +405,7 @@ mod tests {
reads: Vec::new(),
raises: Vec::new(),
traverses: Vec::new(),
..Default::default()
});
let mut g = ModelGraph::new("odoo");
g.models.push(m);
Expand Down Expand Up @@ -419,6 +442,79 @@ mod tests {
assert!(rust.contains("// computed: amount_total <- _compute_amount(line_ids.balance)"));
}

// ───── Rails (compile_graph_ruby) — the convergence proof ─────
//
// The pull-back codegen leg (emit_rust/csharp/python) was, before this
// session, only ever exercised on an Odoo-lifted `CompiledClass`. This
// fixture proves the SAME emitters run unmodified on a Rails-lifted one
// (compile_graph_ruby, ruff#38 + this crate's `mint::compile_graph_ruby`),
// closing the "unproven on Rails" gap named in the OP+Redmine convergence
// handover (openproject-nexgen-rs .claude/handovers/
// 2026-06-30-1200-op-redmine-ogar-convergence-assessment.md §4 step 2).

fn work_package_rail_graph() -> ModelGraph {
let mut m = Model::new("WorkPackage");
m.attributes.push(AttrDecl {
kind: AttrKind::Attribute,
name: "estimated_hours".to_string(),
options: vec![("type".to_string(), "integer".to_string())],
});
// No "type" option → the OgScalar fallback path, same as Odoo's
// `narration` case.
m.attributes.push(AttrDecl {
kind: AttrKind::Attribute,
name: "subject".to_string(),
options: vec![],
});
m.associations.push(AssocDecl {
kind: AssocKind::BelongsTo,
name: "project".to_string(),
options: vec![("class_name".to_string(), "\"Project\"".to_string())],
});
m.associations.push(AssocDecl {
kind: AssocKind::HasMany,
name: "time_entries".to_string(),
options: vec![],
});
let mut g = ModelGraph::new("openproject");
g.models.push(m);
g
}

#[test]
fn emits_rust_struct_for_rails_lifted_class() {
use crate::mint::compile_graph_ruby;
use ogar_vocab::ports::OpenProjectPort;

let cc = &compile_graph_ruby::<OpenProjectPort>(&work_package_rail_graph())[0];
let rust = emit_rust(cc);

assert!(
rust.contains("pub const WORK_PACKAGE_CLASSID: u32 = 0x00010102;"),
"got:\n{rust}"
); // exercises the screaming_snake PascalCase fix below
assert!(rust.contains("pub struct WorkPackage {"));
// Rails `attribute :estimated_hours, :integer` -> OgInt (the same
// og_scalar_type table Odoo's `integer` constructor maps through —
// shared vocabulary across producers, per §1.6).
assert!(rust.contains("pub estimated_hours: OgInt,"), "got:\n{rust}");
// Untyped attribute -> OgScalar fallback.
assert!(rust.contains("pub subject: OgScalar,"), "got:\n{rust}");
// belongs_to with class_name override -> ToOne<Project>, not
// ToOne<Project> from the (singular) relation name by coincidence —
// assert the class_name path specifically by using a relation name
// that would pascal_case differently if class_name were ignored.
assert!(
rust.contains("pub project: ToOne<Project>,"),
"got:\n{rust}"
);
// has_many, no class_name -> pascal_case(time_entries) = TimeEntries.
assert!(
rust.contains("pub time_entries: ToMany<TimeEntries>,"),
"got:\n{rust}"
);
}

#[test]
fn emits_csharp_record_with_wrapper_contract_types() {
let cc = &compile_graph_python::<OdooPort>(&account_move_graph())[0];
Expand Down Expand Up @@ -535,6 +631,19 @@ mod tests {
assert_eq!(screaming_snake("account_move"), "ACCOUNT_MOVE");
}

#[test]
fn screaming_snake_splits_bare_pascal_case_rails_names() {
// Rails class names carry no separator at all (no dots, no
// underscores) — screaming_snake must find the word boundary from
// case alone, or every Rails const collapses to one run-on word.
assert_eq!(screaming_snake("WorkPackage"), "WORK_PACKAGE");
assert_eq!(screaming_snake("TimeEntry"), "TIME_ENTRY");
// Already-snake input is unaffected (the original behaviour).
assert_eq!(screaming_snake("account.move.line"), "ACCOUNT_MOVE_LINE");
// A single PascalCase word with no internal boundary stays whole.
assert_eq!(screaming_snake("Project"), "PROJECT");
}

#[test]
fn og_scalar_type_maps_odoo_constructors() {
assert_eq!(og_scalar_type(Some("char")), "OgStr");
Expand Down
2 changes: 2 additions & 0 deletions crates/ogar-from-ruff/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1334,12 +1334,14 @@ mod tests {
reads: vec!["status".to_string()],
raises: Vec::new(),
traverses: Vec::new(),
..Default::default()
});
m.functions.push(Function {
name: "close!".to_string(),
reads: Vec::new(),
raises: vec!["ArgumentError".to_string()],
traverses: Vec::new(),
..Default::default()
});
m
}
Expand Down
117 changes: 114 additions & 3 deletions crates/ogar-from-ruff/src/mint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ use ogar_vocab::Class;
use ruff_spo_address::{mint_with_classid, Facet, Mint};
use ruff_spo_triplet::{expand, ModelGraph};

use crate::lift_model_graph_python;
use crate::{lift_model_graph, lift_model_graph_python};

/// A class compiled to its rail-shaped, language-agnostic form: the lifted
/// schema ([`Class`]) plus its 16-byte address ([`Facet`]). This is what a
Expand Down Expand Up @@ -83,6 +83,32 @@ pub fn compile_graph_python<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClas
.collect()
}

/// Compile a Ruby/Rails [`ModelGraph`] into rail-shaped [`CompiledClass`]es:
/// lift each model's schema ([`Language::Ruby`](ogar_vocab::Language), via
/// [`lift_model_graph`] — **not** [`lift_model_graph_python`], which would
/// mis-stamp the producer language) and pair it with its minted facet,
/// classid via port `P`. Declaration order is preserved (mirrors
/// [`lift_model_graph`]). The Rails counterpart of
/// [`compile_graph_python`] — same mint, same assembly shape, the only
/// difference is which lift stamps the [`Class::language`](ogar_vocab::Class)
/// field. E.g. `openproject:WorkPackage` → `0x0001_0102`,
/// `redmine:Issue` → `0x0007_0102` (same shared concept, different render
/// prefix — the two-render-skins-one-concept convergence).
#[must_use]
pub fn compile_graph_ruby<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClass> {
let mint = mint_graph::<P>(graph);
lift_model_graph(graph)
.into_iter()
.map(|class| {
let node = format!("{}:{}", graph.namespace, class.name);
let facet = mint
.facet(&node)
.unwrap_or_else(|| Facet::from_parts(classid_for_node::<P>(&node), [0; 6], [0; 6]));
CompiledClass { class, facet }
})
.collect()
}

/// Resolve a node IRI's full render classid via port `P`.
///
/// The IRI is `<ns>:<model>` or `<ns>:<model>.<member>`; members inherit
Expand Down Expand Up @@ -110,8 +136,9 @@ fn model_of(node: &str) -> &str {
#[cfg(test)]
mod tests {
use super::*;
use ogar_vocab::ports::{OdooPort, OpenProjectPort};
use ruff_spo_triplet::{Field, Function, Model};
use ogar_vocab::Language;
use ogar_vocab::ports::{OdooPort, OpenProjectPort, RedminePort};
use ruff_spo_triplet::{AssocDecl, AssocKind, Field, Function, Model};

// A representative `account.move` `ModelGraph`, constructed directly (the
// source→ModelGraph parse is `ruff_python_spo`'s job, tested there). Carries
Expand Down Expand Up @@ -146,6 +173,7 @@ mod tests {
reads: Vec::new(),
raises: Vec::new(),
traverses: Vec::new(),
..Default::default()
});
let mut g = ModelGraph::new("odoo");
g.models.push(m);
Expand Down Expand Up @@ -223,4 +251,87 @@ mod tests {
let facet = mint.facet("odoo:ir_cron").expect("node mints");
assert_eq!(facet.facet_classid(), 0, "unmapped -> bootstrap address");
}

// ───── compile_graph_ruby (Rails: the OP/Redmine convergence path) ─────

/// A representative `WorkPackage` `ModelGraph`, constructed directly (the
/// source→ModelGraph parse is `ruff_ruby_spo`'s job, tested there).
/// Carries an association so the schema-arm projects something besides
/// a bare name.
fn work_package_graph(namespace: &str, model_name: &str) -> ModelGraph {
let mut m = Model::new(model_name);
m.associations.push(AssocDecl {
kind: AssocKind::BelongsTo,
name: "project".to_string(),
options: Vec::new(),
});
m.functions.push(Function {
name: "update_status".to_string(),
..Default::default()
});
let mut g = ModelGraph::new(namespace);
g.models.push(m);
g
}

#[test]
fn compile_graph_ruby_stamps_language_ruby_not_python() {
// The bug compile_graph_python's own doc-comment warns against:
// calling the Python lift on a Rails graph mis-stamps the producer
// language. compile_graph_ruby must route through `lift_model_graph`
// (Language::Ruby), never `lift_model_graph_python`.
let graph = work_package_graph("openproject", "WorkPackage");
let compiled = compile_graph_ruby::<OpenProjectPort>(&graph);
assert_eq!(compiled.len(), 1);
assert_eq!(compiled[0].class.language, Language::Ruby);
}

#[test]
fn openproject_work_package_compiles_to_project_work_item_rail_class() {
let graph = work_package_graph("openproject", "WorkPackage");
let compiled = compile_graph_ruby::<OpenProjectPort>(&graph);
assert_eq!(compiled.len(), 1);
let cc = &compiled[0];

assert_eq!(cc.class.name, "WorkPackage");
assert!(
!cc.class.associations.is_empty(),
"belongs_to :project projects into an association"
);

// OpenProject prefix 0x0001 | project_work_item concept 0x0102.
assert_eq!(cc.facet.facet_classid(), 0x0001_0102);
assert_eq!(cc.facet.facet_classid() & 0xFFFF, 0x0102, "shared concept");
assert_eq!(
(cc.facet.facet_classid() >> 16) as u16,
OpenProjectPort::APP_PREFIX,
"OpenProject render prefix",
);
}

#[test]
fn openproject_and_redmine_compile_to_the_same_concept_different_render_skin() {
// The literal "one canonical concept, two render skins" proof: the
// SAME Rails-shaped ModelGraph (WorkPackage / Issue) minted through
// each fork's port converges on the identical low-u16 concept and
// diverges only on the high-u16 app-render prefix.
let op_graph = work_package_graph("openproject", "WorkPackage");
let rm_graph = work_package_graph("redmine", "Issue");

let op_compiled = compile_graph_ruby::<OpenProjectPort>(&op_graph);
let rm_compiled = compile_graph_ruby::<RedminePort>(&rm_graph);

let op_id = op_compiled[0].facet.facet_classid();
let rm_id = rm_compiled[0].facet.facet_classid();

assert_eq!(op_id & 0xFFFF, rm_id & 0xFFFF, "shared concept converges");
assert_eq!(op_id & 0xFFFF, 0x0102, "project_work_item");
assert_ne!(
(op_id >> 16) as u16,
(rm_id >> 16) as u16,
"render prefixes diverge (OpenProject vs Redmine skin)"
);
assert_eq!((op_id >> 16) as u16, OpenProjectPort::APP_PREFIX);
assert_eq!((rm_id >> 16) as u16, RedminePort::APP_PREFIX);
}
}
Loading
Loading