diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 7d8cf87..82cdc09 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -7,6 +7,122 @@ --- +## 2026-07-05 — E-OGAR-CONVERGENCE-SHAPE — OGAR is the convergence shape: seven layers, one invocation grammar + +**Status:** FRAMING (operator-ruled 2026-07-05; per-layer grades inline; the three open seams and three falsifiers are named — comfort is declared WITH them, not despite them). + +**The ruling (operator, verbatim):** *"Are you comfortable now to make OGAR the convergence shape — Schema, ruff codegen, classview fieldmask, OGIT Ontology as a controller dto, Lance-graph V3 substrate and classview, reasoning vs controller methods as unified surface, ractor kanban as graph execution."* Answered YES, with grades. + +**The composed shape:** + +- **address** — classid (canon concept ‖ render skin) → GUID → the graph key. `[G]` +- **substrate** — lance-graph V3: 512-B node, 2+14 tenants, the 12-slot factoring (6×(part_of:is_a) Rails · 4×SPO generic · 3×SPOG Odoo; D-CHAIN-GROUPING-RESOLVED-12SLOT). `[G]` +- **shape** — the Schema stratum: the global schema WITH an address (ruff `8d6c31b`: `field_type`/`not_null`; the guesses live as harvest data). `[G]` +- **behavior** — `ActionDef`+`KausalSpec` ← ruff writes/calls/callbacks facts. `[G]` facts / `[H]` lowering. +- **render** — classview fieldmask → fieldview fold → askama/jinja (E-ONE-MASK-THREE-PORTS below). `[H]` +- **membrane** — OGIT ontology as controller DTO: wire names from TTL, Auth > RBAC as mask algebra. `[H]` +- **codegen** — ruff codegen: FromRow rows, DTOs, render contexts, constructors are all generated projections, never authored truth. `[G]` +- **execution** — ractor mailbox-kanban: an action executing IS a kanban transition on a mailbox-owned board; scheduling is graph state. `[G]` machinery / `[H]` ERP wiring. +- **cognition** — reasoning methods on the SAME surface: `StepDomain` already unifies Thinking/Query/Persistence/Inference in `UnifiedStep`; controller methods enter as one more port. `[H]` + +**One sentence:** `::()` is the only invocation grammar — MySQL, askama, odoo/op/redmine, and the reasoning stack are all ports; the path is the facet address; the shape is a masked, typed projection. + +**Three open seams (the `[H]`s, named):** (1) the **view stratum** — mint fieldmasks from real views (OP/Redmine ERB, Odoo `ir.ui.view`), the mirror of D-AR-3.5; (2) the **ActionDef ↔ UnifiedStep mapping** — one contract PR joining the ERP action arm to the existing StepDomain union (and the kanban executor); (3) **OGIT TTL → controller-DTO lowering** — today woa-rs practice (wire names from `rdfs:label`), needs to become an OGAR-minted pipeline through `ogar-from-schema`. + +**Three falsifiers:** the WorkPackage parity witnesses (generated model/service vs the hand-port — kills the 85% if they can't approximate); a classview-mask round-trip on one real OP view; one action end-to-end (`op::work_package::update(shape)` → ActionHandler → kanban transition → Lance tombstone). + +**Cross-ref:** E-AR-DIRECT-SDK (2026-07-03), D-CHAIN-GROUPING-RESOLVED-12SLOT, E-ONE-MASK-THREE-PORTS + E-MIRROR-EXTERNALIZATION (below, same arc), openproject-nexgen-rs `.claude/handovers/2026-07-05-ogar-v3-consumer-migration-plan.md`. + +--- + +## 2026-07-05 — E-ONE-MASK-THREE-PORTS — the invocation grammar; one classview bitmask projects three ways + +**Status:** FRAMING (`[G]` for the operator's action calculus, quoted verbatim; `[H]` for the projections until the mask round-trip falsifier runs). + +**The operator's action calculus (verbatim):** *"actions become reusable patterns — MySQL{shape: update} · askama {classview:render} · odoo::part_of::is_a(shape:input[type]) · op::part_of::is_a(shape:input[type]) · redmine::part_of::is_a(shape:input[type])"*. Read as one grammar: **`::()`** — port ∈ {MySQL, askama, odoo, op, redmine, …}, path = the `part_of::is_a` facet navigation (the 12-slot factoring read per port), shape = verb + typed payload. Storage and render are not infrastructure: they are ports with closed verb sets. + +**One mask, three projections.** A classview bitmask over a class's fields, per mode, projects as: (1) the **askama context** (which fields render), (2) the **typed input constructor** (`shape:input[type]` — typed from the schema stratum: `field_type` → Rust type, `not_null` → Option-ness), (3) the **SQL column set** (`MySQL{shape: update}`). Today these drift independently in every ERP; one mask makes the drift a type error. This is what "classviews shape constructors reusable" cashes out to — constructors are GENERATED from classview masks; recipe-bitmask chaining (E-RECIPE-BITMASK-CHAIN) is their composition algebra. + +**What each ancestor teaches:** Redmine/OP ERB — render is a FOLD over the schema (one fieldview partial per column type × skin, ~22 of them, not one template per class); ancestry resolution is longest-prefix (app-specific fieldview, falling back to concept-canonical) — the codebook radix rule again. Odoo — views-as-data is production-proven (`ir.ui.view` records with inheritance = is_a on the render side); `widget=` is Odoo's fieldview selector (independent convergence on field-type→partial dispatch); field-level `groups=` shows **RBAC = mask algebra** (`effective_mask = classview_mask ∧ role_mask`); the closed ORM verb set (create/write/unlink/search) argues for **verb codebook rows** (Odoo `write` ≡ AR `update` ≡ SQL UPDATE — one concept, three spellings; mint question goes on the serialized allocation train, NOT decided here). + +**Next ruff stratum — the view stratum:** parse OP/Redmine ERB (`f.text_field :subject`, …) and Odoo view XML (``) into mode-tagged `renders_field` facts → classview bitmasks get MINTED from the apps' real views, measured, not hand-authored. Same doctrine: mechanism in ruff, oracle-diff before shipping. + +**SDK consequence:** the generated module tree mirrors the `part_of` chain — `op::work_package::update(input)` is a real function whose module path IS the facet address ("the key prerenders nodes with zero value decode", projected into codegen); the one unified adapter is the port trait `invoke(path, shape) → shape`. + +**Cross-ref:** E-OGAR-CONVERGENCE-SHAPE (above), E-AR-DIRECT-SDK, E-RECIPE-BITMASK / E-RECIPE-BITMASK-CHAIN, D-CHAIN-GROUPING-RESOLVED-12SLOT. + +--- + +## 2026-07-05 — E-MIRROR-EXTERNALIZATION — Rails externalizes schema, Odoo externalizes views; OGAR is the fixed point where both come home + +**Status:** FINDING (`[G]` for the mechanism receipts; the closure claim inherits E-OGAR-CONVERGENCE-SHAPE's grades). + +**The operator's question (verbatim):** *"I understand AR's weak side was global Schema, something that Openproject tried to hold on with its ORM training wheels?"* — Confirmed, and sharpened: + +**AR's weak side, precisely:** ActiveRecord models contain NO schema — behavior in code, shape runtime-reflected from the DB; `schema.rb` is a generated artifact. The global schema exists only as two half-truths AR never reconciles: the DB half (`null: false`, unique indexes — enforced, semantically mute) and the model half (`validates presence/uniqueness` — semantic, unenforced). **Receipts in our own pipeline:** live ruff's `extract_fields` stub was structurally honest (there ARE no fields in AR source — hence the D-AR-3.5 schema stratum as a second input), and the harvest rule `null_false_presence` is graded a GUESS because AR itself never linked the halves. + +**OpenProject's compensations, twice:** upstream Ruby OP bolted on Contracts, representers, and the API v3 **Schema endpoints** — literally serving per-resource schema documents at runtime, a schema service as prosthesis; the Rust port hand-typed the same compensation as `op-db` FromRow rows (9,009 LOC) + `op-api` row→DTO (8,592 LOC) — ~17K LOC that is the measurable cost of not having the compiler. The illegitimate training wheel (hand plumbing) retires; the legitimate one (`.claude/harvest/` back-projection, data, measured) is designed to retire. + +**The mirror:** Rails externalizes SCHEMA (DB-reflected) and keeps views in code (ERB); Odoo externalizes VIEWS/ACCESS (`ir.ui.view`, `ir.model.access` as DB records) and keeps schema in code (`fields.Char(...)`). Opposite halves, same motive (runtime flexibility), same cost (static analyzability of the externalized half), and every mature deployment grows prostheses for its missing half. **OGAR is the fixed point:** both halves come home as addressable graph data — schema stratum + model stratum merge into `Class`; views become graph rows (Odoo proved it in production); auth becomes mask algebra; code becomes generated projections. + +**One line:** AR's weakness wasn't missing schema — it was **schema without an address.** OGAR doesn't re-centralize the schema into code; it gives it an address (classid) and mints code from it. + +**Cross-ref:** E-OGAR-CONVERGENCE-SHAPE, E-ONE-MASK-THREE-PORTS (both above), E-KEEP-AR-REMOVE-ORM (2026-06-30 — the open-heart op this entry explains the WHY of), E-VENDOR-DELTA-IS-THE-TRAINING-WHEEL. + +--- + +## 2026-07-05 — E-VENDOR-DELTA-IS-THE-TRAINING-WHEEL — live ruff already subsumes C17a/b/c; the only vendor-unique capability is schema.rb→Field back-projection, which becomes harvest DATA, not a merge + +**Status:** FINDING (`[G]` — verified 2026-07-05 by direct inspection of live ruff main `b459ec3`: 13-variant `Declaration` enum at `crates/ruff_ruby_spo/src/lib.rs:80`, C17-breadth construct handling across `walk.rs`/`lib.rs`/`parse.rs`; and by the openproject-nexgen-rs consumer-migration handover). + +**Corrects E-VENDOR-SPLIT-BRAIN (2026-07-03, below; Status flipped to SUPERSEDED in place per the only-Status-mutable rule).** The "neither fork subsumes the other" claim was wrong: live ruff's `ruff_ruby_spo` natively carries the full C17a/b/c DSL breadth (concerns / enums / store_accessors / attributes / class-meta / scopes / default_scope / callbacks / STI / acts_as, routed through the 13-variant `Declaration` enum). The vendor fork is BEHIND, not ahead — except for exactly one capability: **db/schema.rb column→`Field` extraction** (vendor `scan.rs` + `fields.rs` C4 line-scanner; live `extract_fields` is a documented stub). + +**That one delta does NOT merge into live ruff.** Per the corrected architecture (openproject-nexgen-rs `.claude/handovers/2026-07-05-ogar-v3-consumer-migration-plan.md`): it is the D-AR-3.5 patch = the **ORM→AR back-projection training wheel**, and it becomes resolver CONFIG (data) in op-nexgen `.claude/harvest/` — "config is data; where data is insufficient, make ruff smarter — spec it, don't fake it". The reunification-merge phase planned under the superseded entry is CANCELLED; the un-vendor is a plain git-dep flip. + +**What survives from the superseded entry:** the fuse doctrine — any vendored tree gets a drift fuse or a deletion date; prose cites the fuse test's name, never restates the value it guards. That half stands unchanged. + +**Operator rulings, same arc (2026-07-05, verbatim):** *"Odoo's spog lives now in V3 substrate in lance-graph <>OGAR, not surrealdb AST. And you consume it with fieldview /classview ERB pattern> askama (rust)/jinja (python) based on classview bitmask."* This resolves `docs/DISCOVERY-MAP.md` D-CHAIN-CONSUMPTION-GROUPING — see D-CHAIN-GROUPING-RESOLVED-12SLOT there: the grouping is the **shape-adaptive 12-slot factoring** (`6·2 = 4·3 = 3·4 = 12`: AR/Rails → 6×(part_of:is_a); generic → 4× SPO triplets; Odoo → 3× SPOG quadruplets), carried natively by the lance-graph⟷OGAR V3 substrate; consumption is the ERB fieldview×classview kit rendered via askama (Rust) / jinja (Python), dispatched on the classview bitmask. No SurrealDB AST hosting anywhere. The E-AR-DIRECT-SDK chain-API gate is hereby lifted. + +**Cross-ref:** E-AR-DIRECT-SDK (2026-07-03, below — SDK pillars unchanged, gate lifted); E-VENDOR-SPLIT-BRAIN (superseded, below); openproject-nexgen-rs `.claude/handovers/2026-07-05-ogar-v3-consumer-migration-plan.md` (the corrected-architecture source of record, §2 the 12-slot factoring, §4 the training wheel). + +--- + +## 2026-07-03 — E-AR-DIRECT-SDK — consume the AR shape directly; OGAR serves ERP at the cost of an import; the DO shape lands on ActionHandler + unified adapters + constructor-shaping ClassViews + +**Status:** FRAMING (`[G]` for the operator rulings, quoted verbatim below; `[H]` for the SDK build + the OP retirement path, gated on parity witnesses). + +**The rulings (operator, 2026-07-03, verbatim):** + +1. *"we want to remove the json and the ORM shape from OP and use the rail / AR shape directly making OGAR a transpiler substrate replacing the previous mistake to make surrealQL AST DLL host what is supposed to be compiling substrate to be consumed via part_of/is_a (rails) or triplets (4x (8:8:8), or (3x (8:8:8:8) (odoo ?) and then build the necessary API SDK so that OGAR can serve eg ODOO at the const of an import and class dto store and ERB shaped classview <> askama"* +2. *"basically we take the best of AR rails and ORM and remove the bad and force every substrate to land reusable transpiler substrate with codebook for readability and ontologically converging (eg OGIT Auth > RBAC, DTO maps to odoo if desirable or any other consumer that wants to wire ERP"* +3. *"the DO shape then lands on OGAR actionhandler and unified adapters and classviews that shape constructors reusable"* + +**What it pins:** + +- **Completes E-KEEP-AR-REMOVE-ORM on the consumer side, with both keeps named.** Keep from AR: the domain model as a live object graph (`ClassView`, associations as `part_of`, inheritance as `is_a`). Keep from ORM: compile-time typed schema — the **class DTO store**, types minted once, never runtime-reflected. Remove (the bad): runtime SQL plumbing, JSON-first internal shapes, **DDL text as an API**. JSON survives only at the client membrane — never as internal truth (ADR-022 firewall alignment). +- **SURREAL-AST-AS-ADAPTER extends producer→consumer.** OP's `op-surreal-ast` / DDL-text-as-interface + the JSON row→DTO plumbing are the consumer-side residue of the same "previous mistake" — on a **retirement path**: additive-then-subtractive (W3.3 guardrail governs sequencing, not destination), each removal authorized by its parity witness going green, never ahead of it. +- **The SDK surface (serve an ERP at the cost of an import), four pillars:** (1) one Cargo import; (2) the class DTO store — pull `CompiledClass`/`ClassView` by classid; (3) `part_of`/`is_a` navigation + codebook name resolution for readability (fuse-guarded, `canonical_concept_name`); (4) the ERB-shaped `ClassView ⇄ askama` render contract. One surface for OP / odoo-rs / woa-rs / medcare-rs / any ERP consumer — differing only by port prefix and render skin. **Ontological convergence rides the same rails:** OGIT Auth → RBAC as the pilot; the DTO maps to Odoo where desirable. +- **The DO arm's landing zone, named:** `ActionDef`/`ActionInvocation` facts (writes/calls/callbacks from the reunified Rails extractor) lower onto **OGAR ActionHandler + unified adapters**, with **ClassViews shaping reusable constructors** — the recipe-bitmask + constructor-chaining canon (E-RECIPE-BITMASK, E-RECIPE-BITMASK-CHAIN) is the constructor-shaping mechanism this lands on. +- **Guard:** the SDK's chain-navigation API must NOT freeze a chain-entry grouping until `docs/DISCOVERY-MAP.md` D-CHAIN-CONSUMPTION-GROUPING (OPEN, same arc) is ruled — 4×(8:8:8) vs 3×(8:8:8:8) is the operator's open question, not a decided layout. + +**Cross-ref:** E-KEEP-AR-REMOVE-ORM (2026-06-30, the producer-side half of this ruling); `docs/OGAR-TRANSPILE-SUBSTRATE.md` (85/15, "consumer collapses to a compiler-store caller + adapters, at the cost of an import" — this entry makes that sentence the *deliverable*); `docs/SURREAL-AST-AS-ADAPTER.md`; D-CHAIN-CONSUMPTION-GROUPING (the OPEN grouping ruling). Extractor prerequisite: E-VENDOR-SPLIT-BRAIN (below, same day) — the ruff_ruby_spo reunification is Phase 1 of the execution arc. + +--- + +## 2026-07-03 — E-VENDOR-SPLIT-BRAIN — openproject-nexgen-rs's vendored ruff crates are a FORK developed in place, not a stale snapshot; the planned re-vendor would have destroyed three sprints + +**Status:** SUPERSEDED (2026-07-05 — the "neither subsumes the other" claim was WRONG: live ruff main already carries the C17 DSL breadth; the only vendor-unique capability is the schema.rb→Field back-projection, which becomes harvest DATA, not a merge. See E-VENDOR-DELTA-IS-THE-TRAINING-WHEEL above. The vendored-tree fuse-doctrine half of this entry stands.) + +**The finding.** The OP vendor tree and live ruff `ruff_ruby_spo` diverged in BOTH directions: the **vendor fork** carries C17a (lib-ruby-parser typed-AST class shape, 1469-line `parse.rs`) + C17b (concerns/enums/store_accessors/attributes/class-meta, gaps G8–G14) + C17c (ignored_columns/scopes/default_scope/callbacks/collection callbacks, G13/G15–G17/G19/G20) — on the OLD skinny IR (103-line `ir.rs`, no `AssocDecl`). The **live repo** carries the rich IR (742-line `ir.rs`: `AssocDecl`, `field_type`, validates→required, writes/calls #38) + `extract_app` engine-walking + class-reopen merging — without the C17 DSL breadth. Frontend breadth on one side, IR depth on the other; **neither subsumes the other**, and the version numbers lie (vendor claims 0.2.0, live says 0.1.0). The planned "re-vendor" (`rm -rf` + copy live→vendor) would have silently deleted ~1,900 LOC of unmerged sprint work. + +**The correction:** reunify INTO live ruff (the canonical home) — merge spec derived mechanically by running BOTH extractors over the union of fixtures and diffing triple sets — then OP drops the vendor for a rev-pinned git dep (the lock-pin-bump ritual OP already runs for OGAR). Deletion of the vendor tree only after the A/B triple-diff fuse is green. + +**The doctrine (fuse extension):** any vendored tree gets a **drift fuse or a deletion date** — a test pinning e.g. `Predicate::ALL.len()` against the recorded upstream count would have screamed at ruff#34 instead of rotting silently. Corollary from the same arc: prose that restates code state rots on every flip — prose should cite the fuse test's *name*, not restate the value it guards (the #147 post-flip sweep needed 6 prose sites; this pattern kills the class). + +**Cross-ref:** E-AR-DIRECT-SDK (above — this reunification is its extractor prerequisite); 66316fa's fuse pattern (`classid_order_agrees_with_lance_graph_contract_canon_high`, COUNT_FUSE) as the model being extended; ruff#38 (writes/calls) + OP vendor C17c (callbacks) = the DO-arm's two fact sources, unified by this merge. + +--- + ## 2026-07-02 — E-CLASSID-CANON-HIGH-FLIP — the composed classid half-order flips: canon concept HIGH, APP/render prefix LOW **Status:** FINDING (`[G]`, operator-triggered). Doc-sweep companion to `docs/DISCOVERY-MAP.md` D-CLASSID-CANON-HIGH-FLIP — read that entry for the full ledger; this entry records the same correction in the session-findings log. diff --git a/crates/ogar-from-rails/Cargo.toml b/crates/ogar-from-rails/Cargo.toml index c8f1ea0..071beec 100644 --- a/crates/ogar-from-rails/Cargo.toml +++ b/crates/ogar-from-rails/Cargo.toml @@ -19,7 +19,12 @@ serde = [ [dependencies] ogar-vocab = { path = "../ogar-vocab" } ogar-from-ruff = { path = "../ogar-from-ruff" } -# Same exact ruff rev as ogar-from-ruff (they share ruff_spo_triplet -# internally; a rev mismatch double-checks-out ruff). Bump in lockstep. -ruff_ruby_spo = { git = "https://github.com/AdaWorldAPI/ruff", rev = "45b6e62e397528a88683376596256f9a435c1946" } +# Operator ruling: rev pins on AdaWorldAPI-internal git deps are retired — +# they were "out of desperation to lock what hadn't converged in transpiler +# sink-in yet." Drift protection is the fuse tests, not pins. Floats on the +# convergence branch `claude/odoo-rs-transcode-lf8ya5` in lockstep with +# ogar-from-ruff (they share ruff_spo_triplet internally). Flips to +# `branch = "main"` once the convergence branches merge. Cargo.lock still +# locks a concrete rev for reproducibility. +ruff_ruby_spo = { git = "https://github.com/AdaWorldAPI/ruff", branch = "claude/odoo-rs-transcode-lf8ya5" } serde = { workspace = true, optional = true } diff --git a/crates/ogar-from-ruff/Cargo.toml b/crates/ogar-from-ruff/Cargo.toml index 954d532..c7571c7 100644 --- a/crates/ogar-from-ruff/Cargo.toml +++ b/crates/ogar-from-ruff/Cargo.toml @@ -14,13 +14,14 @@ serde = ["dep:serde", "ogar-vocab/serde"] [dependencies] ogar-vocab = { path = "../ogar-vocab" } -# ruff pinned to an exact rev (not floating `branch = "main"`): this crate -# reads `ruff_spo_triplet::Model::inherits` (ruff PR #40) and the DO-arm -# controller harvest reads `ruff_ruby_spo::extract_tree_with` (added on the -# medcare-bridge branch, rev `45b6e62`). With no committed Cargo.lock a floating -# branch could resolve to a rev WITHOUT those and fail to compile. Both ruff -# crates MUST share one rev (they depend on ruff_spo_triplet internally; a -# mismatch double-checks-out ruff). Bump in lockstep with ogar-from-rails. -ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", rev = "45b6e62e397528a88683376596256f9a435c1946" } -ruff_spo_address = { git = "https://github.com/AdaWorldAPI/ruff", rev = "45b6e62e397528a88683376596256f9a435c1946" } +# Operator ruling: rev pins on AdaWorldAPI-internal git deps are retired — +# they were "out of desperation to lock what hadn't converged in transpiler +# sink-in yet." Drift protection is the fuse tests, not pins. Floats on the +# convergence branch `claude/odoo-rs-transcode-lf8ya5` (shared with +# ogar-from-rails; bump the branch reference in lockstep if it ever +# renames). Flips to `branch = "main"` once the convergence branches merge. +# Cargo.lock still locks a concrete rev for reproducibility — this is not a +# return to floating `main` unpinned. +ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", branch = "claude/odoo-rs-transcode-lf8ya5" } +ruff_spo_address = { git = "https://github.com/AdaWorldAPI/ruff", branch = "claude/odoo-rs-transcode-lf8ya5" } serde = { workspace = true, optional = true } diff --git a/crates/ogar-from-ruff/src/emit.rs b/crates/ogar-from-ruff/src/emit.rs index 5823aaf..ecb4cfe 100644 --- a/crates/ogar-from-ruff/src/emit.rs +++ b/crates/ogar-from-ruff/src/emit.rs @@ -23,19 +23,31 @@ //! re-implementation — the layer-1 / layer-2 story of substrate doc §1.6. //! //! Scalar attributes emit a **concrete wrapper type** mapped from the Odoo -//! constructor carried on `Attribute::type_name` (the ruff `field_type` -//! predicate): `char`/`text`/`html` → `OgStr`, `integer` → `OgInt`, -//! `float` → `OgFloat`, `monetary` → `OgMoney` (Decimal-backed), `boolean` → -//! `OgBool`, `date`/`datetime` → `OgDate`/`OgDateTime`, `binary` → `OgBytes`, +//! constructor OR the Rails migration-DSL column type — both arrive on +//! `Attribute::type_name` (the ruff `field_type` predicate is shared by both +//! producers): `char`/`text`/`html`/`string` → `OgStr`, `integer`/`bigint` → +//! `OgInt`, `float` → `OgFloat`, `monetary`/`decimal` → `OgMoney` +//! (Decimal-backed — `decimal` shares the wrapper pending a dedicated +//! `OgDecimal`, see [`og_scalar_type`]'s doc), `boolean` → `OgBool`, +//! `date`/`datetime` → `OgDate`/`OgDateTime`, `binary` → `OgBytes`, //! `selection` → `OgSelection`, `json` → `OgJson`; an untyped/unknown column //! falls back to the generic `OgScalar`. See [`og_scalar_type`]. //! +//! An attribute whose `AttributeOptions::required == Some(false)` (Rails +//! schema-stratum columns project this explicitly — see +//! `ogar-from-ruff::project_rails_fields`) emits wrapped in `Option<...>` +//! in [`emit_rust`] — nullable-at-the-database IS optional-in-Rust. Any +//! other `required` value (`Some(true)`, or `None` — the Odoo path today, +//! which never sets `required`) emits the bare wrapper type unchanged, so +//! the Odoo emit output is bit-for-bit stable across this change. +//! //! Field identifiers that collide with a target-language reserved word are //! escaped (see [`escape_ident`]) so the emitted source compiles — e.g. an //! Odoo field named `type` / `ref` becomes `r#type` / `r#ref` in Rust and //! `@type` / `@ref` in C#. Python source field names cannot be keywords (the //! Odoo source would not parse), so Python emit needs no escaping. +use ogar_vocab::app::{app_of, concept_of}; use ogar_vocab::{Association, AssociationKind}; use crate::mint::CompiledClass; @@ -55,19 +67,32 @@ fn assoc_target(assoc: &Association) -> (String, bool) { (target, is_many) } -/// Map an Odoo field constructor (lowercased, carried on `Attribute::type_name` -/// as the ruff `field_type` predicate) to the consumer wrapper-contract scalar -/// type. Shared by all three emitters — the type NAMES are identical across +/// Map an Odoo field constructor OR a Rails migration-DSL column type +/// (lowercased, both carried on `Attribute::type_name` as the shared ruff +/// `field_type` predicate) to the consumer wrapper-contract scalar type. +/// Shared by all three emitters — the type NAMES are identical across /// languages (§1.6). `None` (type not captured) and any unrecognised /// constructor fall back to the generic `OgScalar`, so the emit is always -/// well-typed. `monetary` → `OgMoney` is Decimal-backed (the ERP money -/// doctrine), never a float. +/// well-typed. +/// +/// `monetary`/`decimal` → `OgMoney` is Decimal-backed (the ERP money +/// doctrine), never a float. There is no dedicated `OgDecimal` wrapper in +/// the consumer contract today — `decimal` (Rails `t.decimal`, arbitrary +/// precision, not necessarily currency) shares `OgMoney` as the closest +/// existing Decimal-backed wrapper rather than falling back to the +/// untyped `OgScalar`; a future `OgDecimal` should take over this arm +/// without touching `monetary`. +/// +/// `string`/`bigint` are the Rails migration-DSL column-type tokens +/// (`ruff_ruby_spo::schema::COLUMN_TYPES`) for what Odoo spells +/// `char`/… and `integer` respectively — same wrapper, different source +/// vocabulary. fn og_scalar_type(type_name: Option<&str>) -> &'static str { match type_name { - Some("char" | "text" | "html") => "OgStr", - Some("integer") => "OgInt", + Some("char" | "text" | "html" | "string") => "OgStr", + Some("integer" | "bigint") => "OgInt", Some("float") => "OgFloat", - Some("monetary") => "OgMoney", + Some("monetary" | "decimal") => "OgMoney", Some("boolean") => "OgBool", Some("date") => "OgDate", Some("datetime") => "OgDateTime", @@ -258,10 +283,11 @@ pub fn emit_rust(cc: &CompiledClass) -> String { let mut out = String::new(); out.push_str(&format!( - "/// Rail class `{}` — classid `0x{:08X}` (concept `0x{:04X}`).\n", + "/// Rail class `{}` — classid `0x{:08X}` (concept `0x{:04X}`, app `0x{:04X}`).\n", cc.class.name, cc.facet.facet_classid(), - cc.facet.facet_classid() as u16, + concept_of(cc.facet.facet_classid()), + app_of(cc.facet.facet_classid()), )); out.push_str(&format!( "pub const {}_CLASSID: u32 = 0x{:08X};\n\n", @@ -271,10 +297,21 @@ pub fn emit_rust(cc: &CompiledClass) -> String { out.push_str(&format!("pub struct {ty} {{\n")); for attr in &cc.class.attributes { + let base_ty = og_scalar_type(attr.type_name.as_deref()); + // `required == Some(false)` is the ONLY nullability signal that + // wraps in `Option<...>` — the Rails schema stratum sets this + // explicitly (see `project_rails_fields`). `Some(true)` and `None` + // (the Odoo path today, which never sets `required`) both emit the + // bare wrapper type unchanged, so Odoo's emit output is bit-for-bit + // stable across this change (GAP-4, falsifier #1 gap-close). + let field_ty = if attr.options.required == Some(false) { + format!("Option<{base_ty}>") + } else { + base_ty.to_string() + }; out.push_str(&format!( - " pub {}: {},\n", + " pub {}: {field_ty},\n", escape_ident(&attr.name, Lang::Rust), - og_scalar_type(attr.type_name.as_deref()), )); } for assoc in &cc.class.associations { @@ -322,10 +359,11 @@ pub fn emit_csharp(cc: &CompiledClass) -> String { let mut out = String::new(); out.push_str(&format!( - "/// Rail class {} — classid 0x{:08X} (concept 0x{:04X}).\n", + "/// Rail class {} — classid 0x{:08X} (concept 0x{:04X}, app 0x{:04X}).\n", cc.class.name, cc.facet.facet_classid(), - cc.facet.facet_classid() as u16, + concept_of(cc.facet.facet_classid()), + app_of(cc.facet.facet_classid()), )); out.push_str(&format!("public sealed record {ty}\n{{\n")); out.push_str(&format!( @@ -380,10 +418,11 @@ pub fn emit_python(cc: &CompiledClass) -> String { out.push_str("@dataclass\n"); out.push_str(&format!("class {ty}:\n")); out.push_str(&format!( - " \"\"\"Rail class `{}` — classid 0x{:08X} (concept 0x{:04X}).\"\"\"\n", + " \"\"\"Rail class `{}` — classid 0x{:08X} (concept 0x{:04X}, app 0x{:04X}).\"\"\"\n", cc.class.name, cc.facet.facet_classid(), - cc.facet.facet_classid() as u16, + concept_of(cc.facet.facet_classid()), + app_of(cc.facet.facet_classid()), )); out.push_str(&format!( " CLASSID: ClassVar[int] = 0x{:08X}\n", @@ -627,6 +666,117 @@ mod tests { ); } + // ───── D-AR-3.5 schema-stratum projection at emit time (falsifier #1 gap-close) ───── + // + // `Model::fields` (physical schema) is separate from the `attributes` / + // `associations` AR-DSL vectors exercised by `work_package_rail_graph` + // above. This fixture carries both, plus an FK column shadowed by a + // declared association, so the emit-level gap-close (GAP-3 type table + + // GAP-4 nullability wrapping) is proven end-to-end through + // `compile_graph_ruby` -> `emit_rust`, not just at the `lift_model` + // level (see `lib.rs`'s `lift_model_ruby_*` tests for the lift-level + // proof). + + fn work_package_schema_graph() -> ModelGraph { + let mut m = Model::new("WorkPackage"); + m.associations.push(AssocDecl { + kind: AssocKind::BelongsTo, + name: "project".to_string(), + options: vec![], + }); + m.associations.push(AssocDecl { + kind: AssocKind::HasMany, + name: "time_entries".to_string(), + options: vec![], + }); + m.fields.push(Field { + name: "id".to_string(), + field_type: Some("bigint".to_string()), + not_null: Some(true), + ..Default::default() + }); + m.fields.push(Field { + name: "subject".to_string(), + field_type: Some("string".to_string()), + not_null: Some(true), + ..Default::default() + }); + m.fields.push(Field { + name: "description".to_string(), + field_type: Some("text".to_string()), + not_null: None, + ..Default::default() + }); + // Shadowed by the `project` association above — must NOT double-emit. + m.fields.push(Field { + name: "project_id".to_string(), + field_type: Some("bigint".to_string()), + not_null: Some(true), + ..Default::default() + }); + let mut g = ModelGraph::new("openproject"); + g.models.push(m); + g + } + + /// **(c)** GAP-3 (Rails `bigint`/`string` reach the same `og_scalar_type` + /// table Odoo's `integer`/`char` do) + GAP-4 (`required == Some(false)` + /// wraps in `Option<...>`; `Some(true)` stays bare) + the FK-dedup from + /// GAP-1, all proven together through the real `compile_graph_ruby` -> + /// `emit_rust` path. + #[test] + fn emits_rust_struct_with_typed_and_optional_schema_fields_for_rails() { + use crate::mint::compile_graph_ruby; + use ogar_vocab::ports::OpenProjectPort; + + let cc = &compile_graph_ruby::(&work_package_schema_graph())[0]; + let rust = emit_rust(cc); + + // bigint -> OgInt (GAP-3); not_null Some(true) -> required Some(true) + // -> bare type (GAP-4). + assert!(rust.contains("pub id: OgInt,"), "got:\n{rust}"); + // string -> OgStr (GAP-3); not_null Some(true) -> bare type (GAP-4). + assert!(rust.contains("pub subject: OgStr,"), "got:\n{rust}"); + // text -> OgStr; not_null None -> required Some(false) -> Option<...> (GAP-4). + assert!( + rust.contains("pub description: Option,"), + "got:\n{rust}" + ); + // project_id is FK-deduped: the `project` association already + // carries the relation, so the scalar column must not double-emit. + assert!( + !rust.contains("project_id:"), + "project_id must be shadowed by the project association:\n{rust}" + ); + assert!( + rust.contains("pub project: ToOne,"), + "got:\n{rust}" + ); + assert!( + rust.contains("pub time_entries: ToMany,"), + "got:\n{rust}" + ); + } + + /// **(d)** cosmetic gap-close: the emitted doc line must print the TRUE + /// concept half (`0x0102`, the CANON high half since the 2026-07-02 + /// canon-high flip) and the app half (`0x0001`, OpenProject's render + /// prefix) separately — not `facet_classid() as u16` (which reads the + /// LOW/app half and mislabels it "concept"). + #[test] + fn emit_rust_doc_line_prints_concept_high_and_app_low() { + use crate::mint::compile_graph_ruby; + use ogar_vocab::ports::OpenProjectPort; + + let cc = &compile_graph_ruby::(&work_package_schema_graph())[0]; + let rust = emit_rust(cc); + + assert!( + rust.contains("classid `0x01020001` (concept `0x0102`, app `0x0001`)"), + "got:\n{rust}" + ); + } + #[test] fn emits_csharp_record_with_wrapper_contract_types() { let cc = &compile_graph_python::(&account_move_graph())[0]; diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index 78703d8..55515cd 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -37,6 +37,7 @@ //! | `sti.inherits_from` | `parent` | STI parent — matches `Class.parent` slot | //! | `inherits` | `mixins` (appended) | Odoo `_inherit` multi-parent mixin composition — the vocab's `mixins` doc names `_inherit`; the `inheritance` axis excludes mixins. Frontend-agnostic field, populated only by the Odoo frontend | //! | `functions` | `Vec` (DO-arm) | [`lift_actions`] — one `ActionDef` per method; standalone, not on `Class` | +//! | `fields` | `attributes` / `associations` / `computed_fields` | the D-AR-3.5 physical schema stratum (migration-DSL columns for Rails via [`project_rails_fields`]; `fields.X(...)` declarations for Odoo via [`project_odoo_fields`]); `not_null` wires `AttributeOptions::required` (Rails only); a Rails `_id` FK column is skipped when the model also declares an association named `` (double-strata dedup) | //! //! Fields NOT lifted today (no equivalent on the ruff side OR no clean //! semantic mapping): @@ -194,12 +195,22 @@ fn lift_model_with_language(model: &Model, language: Language) -> Class { class.callbacks = model.callbacks.iter().map(lift_callback).collect(); class.validations = model.validations.iter().filter_map(lift_validation).collect(); class.default_scope = lift_default_scope(model); - // Rails carries its schema in the AR-DSL vectors lifted above; an Odoo - // model instead declares everything as `fields.X(...)`, which lands in - // the core-7 `Model::fields` vector (empty for Rails). Project it so the - // Python lift doesn't drop the schema (Codex P1, PR #131). - if matches!(language, Language::Python) { - project_odoo_fields(&mut class, model); + // Rails carries its DECLARED schema in the AR-DSL vectors lifted above + // (`attribute :x, :type`, `belongs_to :y`); an Odoo model instead + // declares everything as `fields.X(...)`, which lands in the core-7 + // `Model::fields` vector. Both frontends ALSO populate `Model::fields` + // with the D-AR-3.5 *physical* schema stratum (Rails: the migration DSL + // columns via `ruff_ruby_spo::extract_app_with_schema`; Odoo: the same + // vector doubles as the declared schema since Odoo has no separate + // AR-DSL). So each language gets its own field-projection pass — + // `project_odoo_fields` for Python (Codex P1, PR #131), and + // `project_rails_fields` for Ruby (falsifier #1 gap-close, + // D-PARITY-PROBE-WP-1) — so neither producer silently drops the schema + // stratum lifted by its frontend. + match language { + Language::Python => project_odoo_fields(&mut class, model), + Language::Ruby => project_rails_fields(&mut class, model), + _ => {} } class } @@ -246,6 +257,83 @@ fn project_odoo_fields(class: &mut Class, model: &Model) { } } +/// Project a Rails model's D-AR-3.5 schema stratum — the PHYSICAL DB columns +/// in `Model::fields`, populated by `ruff_ruby_spo::extract_app_with_schema` +/// from the `db/migrate/tables/*.rb` migration DSL — onto the schema-carrying +/// [`Class`] columns. Ruby-only counterpart of [`project_odoo_fields`]: the +/// AR-DSL vectors (`attributes` / `associations`, from `attribute :x, :type` +/// / `belongs_to :y`) carry the *declared* schema and are lifted separately, +/// earlier in [`lift_model_with_language`]; `fields` carries the *physical* +/// schema (what the migration actually created). Both strata are real and +/// both must reach the [`Class`], or the Rails lift silently drops the +/// physical column set the way the pre-fix Python-only gate did (falsifier +/// #1 / D-PARITY-PROBE-WP-1). +/// +/// Per-field mapping (mirrors [`project_odoo_fields`]): +/// - relational field (`target` set) → [`Association`]; same shape as the +/// Odoo path. `ruff_ruby_spo::schema` never sets `target` today (the +/// migration DSL has no relation-aware column form), so this arm is +/// dormant for Rails until a future relation-aware schema pass — kept +/// for shape-parity with Odoo rather than speculative dead code. +/// - scalar field named `_id` where the model ALSO declares an AR-DSL +/// association named `` (already lifted onto `class.associations` +/// earlier in [`lift_model_with_language`]) → **skipped**. The FK column +/// (physical stratum) and the declared `belongs_to`/`has_many` (declared +/// stratum) are the SAME relation seen twice; projecting both would +/// double-report it as `_id: OgInt` (the ORM spelling) AND +/// `: ToOne` (the AR spelling) on the same [`Class`]. The canon +/// keeps the AR spelling — see [`is_fk_shadowed_by_association`]. The +/// literal primary key `id` never matches this pattern (it carries no +/// `_id`-suffixed prefix of its own) and is always kept. +/// - other scalar field → [`Attribute`] with `type_name` from +/// `Field::field_type` (the migration DSL type token verbatim: `string`, +/// `bigint`, `integer`, …), and `AttributeOptions::required` wired from +/// `Field::not_null`: `Some(true)` (`null: false` in the migration) maps +/// to `Some(true)`; `None` (nullable — Rails' column default, and the only +/// other value `not_null` carries per its own doc) maps to `Some(false)`. +/// The schema stratum is total knowledge here — every column has a real +/// nullability, so absence of `null: false` IS a positive "nullable" fact, +/// not "unknown" the way an Odoo `required=` kwarg's absence would be. +/// - compute field (`emitted_by` set — the D-AR-3.5 compute-linkage pass, +/// `ruff_ruby_spo::schema::link_computed_fields`) → [`ComputedField`], +/// same as the Odoo path. +fn project_rails_fields(class: &mut Class, model: &Model) { + for field in &model.fields { + if let Some(comodel) = &field.target { + let kind = odoo_relation_kind(field.relation_kind.as_deref(), field.inverse_name.is_some()); + let mut assoc = Association::new(kind, &field.name); + assoc.class_name = Some(comodel.clone()); + assoc.inverse_of = field.inverse_name.clone(); + class.associations.push(assoc); + } else if !is_fk_shadowed_by_association(&field.name, class) { + let mut attr = Attribute::new(&field.name); + attr.type_name = field.field_type.clone(); + // Schema stratum is total: null:false -> required; the Rails + // default (nullable, `not_null == None`) -> explicitly optional. + attr.options.required = Some(field.not_null.unwrap_or(false)); + class.attributes.push(attr); + } + if let Some(compute_method) = &field.emitted_by { + let mut computed = ComputedField::new(&field.name, compute_method); + computed.depends = field.depends_on.clone(); + class.computed_fields.push(computed); + } + } +} + +/// FK-dedup predicate for [`project_rails_fields`]: true when `field_name` +/// has the shape `_id` for a non-empty ``, AND `class` already +/// carries an [`Association`] named `` (from the AR-DSL vector, lifted +/// before this function runs in [`lift_model_with_language`]). The bare `id` +/// primary key column never matches — `"id"` has no `"_id"`-suffixed prefix +/// of its own — and is always kept as a scalar attribute. +fn is_fk_shadowed_by_association(field_name: &str, class: &Class) -> bool { + field_name + .strip_suffix("_id") + .filter(|prefix| !prefix.is_empty()) + .is_some_and(|prefix| class.associations.iter().any(|a| a.name == prefix)) +} + /// Map an Odoo relation cardinality to the canonical [`AssociationKind`]. /// /// `relation_kind` (`many2one` / `one2many` / `many2many`, from ruff's @@ -884,15 +972,131 @@ mod tests { assert_eq!(computed.depends, vec!["line_ids.balance".to_string()]); } + /// A Rails-shape model carrying BOTH the AR-DSL `associations` vector + /// (declared schema — `belongs_to :project`, `has_many :time_entries`) + /// AND the D-AR-3.5 physical schema stratum (`Model::fields`, the shape + /// `ruff_ruby_spo::extract_app_with_schema` produces from the migration + /// DSL). This is exactly the shape the pre-fix Rails lift silently + /// dropped (falsifier #1 / D-PARITY-PROBE-WP-1): `project_odoo_fields` + /// was Python-only, so `Model::fields` never reached the `Class` for a + /// Ruby-language lift. + fn mk_rails_schema_model() -> Model { + let mut m = Model::new("WorkPackage"); + m.associations.push(AssocDecl { + kind: AssocKind::BelongsTo, + name: "project".to_string(), + options: vec![], + }); + m.associations.push(AssocDecl { + kind: AssocKind::HasMany, + name: "time_entries".to_string(), + options: vec![], + }); + m.fields.push(Field { + name: "id".to_string(), + field_type: Some("bigint".to_string()), + not_null: Some(true), + ..Default::default() + }); + m.fields.push(Field { + name: "subject".to_string(), + field_type: Some("string".to_string()), + not_null: Some(true), + ..Default::default() + }); + m.fields.push(Field { + name: "description".to_string(), + field_type: Some("text".to_string()), + not_null: None, + ..Default::default() + }); + // The FK column that duplicates the `project` association above — + // must be shadowed, not double-projected as a scalar attribute. + m.fields.push(Field { + name: "project_id".to_string(), + field_type: Some("bigint".to_string()), + not_null: Some(true), + ..Default::default() + }); + m + } + + /// **(a)** GAP-1: the Ruby lift must project the D-AR-3.5 schema stratum + /// (`Model::fields`), same as the Python lift does for Odoo's `fields`. + /// Before this fix, `lift_model_ruby_does_not_project_fields` pinned the + /// OLD (bug) behaviour — a Rails-shape model's physical columns never + /// reached `Class.attributes` at all. #[test] - fn lift_model_ruby_does_not_project_fields() { - // The Rails lift reads the AR-DSL vectors, never `Model::fields` - // (Rails also populates `fields`, so projecting them would - // double-count). An Odoo-shape model lifted as Ruby stays empty. - let class = lift_model(&mk_odoo_model()); - assert!(class.attributes.is_empty()); - assert!(class.associations.is_empty()); - assert!(class.computed_fields.is_empty()); + fn lift_model_ruby_projects_schema_stratum_fields_with_types() { + let class = lift_model(&mk_rails_schema_model()); + let by_name = |n: &str| { + class + .attributes + .iter() + .find(|a| a.name == n) + .unwrap_or_else(|| panic!("{n} must be projected as an attribute")) + }; + assert_eq!(by_name("id").type_name.as_deref(), Some("bigint")); + assert_eq!(by_name("subject").type_name.as_deref(), Some("string")); + assert_eq!(by_name("description").type_name.as_deref(), Some("text")); + } + + /// **(b)** GAP-1 FK-dedup: a scalar `_id` column must NOT also + /// surface as an `Attribute` when the model declares an association + /// named `` — the physical FK column and the declared + /// `belongs_to` are the SAME relation seen from two schema strata, and + /// double-projecting it would emit both `project_id: OgInt` (ORM + /// spelling) and `project: ToOne` (AR spelling) for one + /// relation. The bare `id` primary key is never shadowed (it carries no + /// `_id`-suffixed prefix of its own). + #[test] + fn lift_model_ruby_fk_scalar_deduped_against_declared_association_id_kept() { + let class = mk_rails_schema_model(); + let lifted = lift_model(&class); + assert!( + !lifted.attributes.iter().any(|a| a.name == "project_id"), + "project_id must be shadowed by the `project` association: {:?}", + lifted.attributes, + ); + assert!( + lifted.attributes.iter().any(|a| a.name == "id"), + "the literal id column is never FK-deduped" + ); + assert!(lifted.associations.iter().any(|a| a.name == "project")); + } + + /// **(c)** GAP-1 required wiring: `Field::not_null` maps onto + /// `AttributeOptions::required` — `Some(true)` (`null: false`) stays + /// `Some(true)`; `None` (Rails' nullable default) becomes the EXPLICIT + /// `Some(false)`, since the schema stratum is total knowledge (every + /// column has a real nullability). See `emit.rs`'s + /// `emit_rust_doc_line_prints_concept_high_and_app_low` / + /// `emits_rust_struct_with_typed_and_optional_schema_fields_for_rails` + /// for the paired emit-side proof (bare type vs `Option<...>`). + #[test] + fn lift_model_ruby_wires_not_null_to_required() { + let class = lift_model(&mk_rails_schema_model()); + let by_name = |n: &str| class.attributes.iter().find(|a| a.name == n).unwrap(); + assert_eq!(by_name("subject").options.required, Some(true)); + assert_eq!(by_name("description").options.required, Some(false)); + assert_eq!(by_name("id").options.required, Some(true)); + } + + /// The Odoo path is completely unaffected by the Rails schema-field + /// projection (GAP-1 is additive per-language, not a shared code path + /// change): `project_odoo_fields` never sets `required` at all, so + /// `AttributeOptions::required` stays `None` for every Odoo-lifted + /// attribute — zero drift on the existing Odoo lift/emit tests. + #[test] + fn lift_model_python_never_sets_required_zero_drift() { + let class = lift_model_python(&mk_odoo_model()); + for attr in &class.attributes { + assert_eq!( + attr.options.required, None, + "Odoo path must not set required on {}", + attr.name + ); + } } #[test] diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index 28a1de3..2b44ab4 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -767,3 +767,88 @@ isolation. The map's job is to keep them visible. state }` (dropping field 1 `amount_total`), `fn new(name, state)`, and `fn action_post(&mut self)` with `CLASS_ID = 0x0202`. Closes the transpile chain with LEG 1 (ruff #40) + LEG 2 (D-OGAR-ODOO-INHERIT-MIXINS). +- **D-CHAIN-CONSUMPTION-GROUPING (operator framing 2026-07-03; + RESOLVED 2026-07-05 — see D-CHAIN-GROUPING-RESOLVED-12SLOT below, + grade lives there; the SDK gate is lifted):** the operator's + transpiler-substrate reminder names two consumption geometries for + the substrate: *"to be consumed via part_of/is_a (rails) or triplets + (4x (8:8:8), or (3x (8:8:8:8) (odoo ?)"* (verbatim, question mark + included — the Odoo grouping is explicitly open). Candidate readings, + recorded so the ruling has something concrete to confirm or correct: + **(a)** the 12 bytes of facet tier chains (`part_of[6] + is_a[6]`) + admit two groupings — **4×(8:8:8)** = four 3-byte compressed-classid + entries (deeper ancestry, compressed refs; the Rails hierarchical + read), vs **3×(8:8:8:8)** = three full 4-byte `u32` classids (fewer + levels, full fidelity; the Odoo triplet-stream read). Same 12 bytes, + two groupings — a byte-level echo of the 3×4-vs-4×3 standing watch + (`CLAUDE.md`). **(b)** the 3-byte entry spelling could be + `domain:appid:classview-byte` per D-CLASSID-HI-U16-SPELLING's byte + registers (drops one lo-half byte per entry). **Consequence either + way:** Rails consumers navigate hierarchically (`part_of`/`is_a` — + the AR object-graph read); triplet-flavored consumers stream SPO + groupings — one chain field, two reads, so the grouping choice is a + *view*, not a layout fork, IF entry width is settled. Gate: OGAR SDK + chain-navigation API (see EPIPHANIES E-AR-DIRECT-SDK) blocks on this + entry's ruling. + +- **D-CHAIN-GROUPING-RESOLVED-12SLOT (operator ruling + upstream + handover; 2026-07-05; [G]):** resolves D-CHAIN-CONSUMPTION-GROUPING + (above; status regraded in place): the grouping is not a layout fork + but the **shape-adaptive 12-slot factoring** of the V3 node — + `6·2 = 4·3 = 3·4 = 12`: AR/Rails → **6×(part_of : is_a)** (mereology × + taxonomy), generic → **4× SPO triplets**, Odoo → **3× SPOG + quadruplets** — one substrate, the factoring chosen per source shape. + Odoo's SPOG lives in the lance-graph⟷OGAR **V3 substrate, NOT a + SurrealDB AST** (operator verbatim 2026-07-05: *"Odoo's spog lives now + in V3 substrate in lance-graph <>OGAR, not surrealdb AST"*); + consumption is the ERB **fieldview×classview** pattern rendered via + **askama (Rust) / jinja (Python)**, dispatched on the **classview + bitmask** (operator verbatim: *"you consume it with fieldview + /classview ERB pattern> askama (rust)/jinja (python) based on + classview bitmask"*). Consequence: the E-AR-DIRECT-SDK chain-API gate + is lifted — the SDK's navigation API implements the three factoring + VIEWS over one 12-slot field, never three layouts. Source of record: + openproject-nexgen-rs + `.claude/handovers/2026-07-05-ogar-v3-consumer-migration-plan.md` §2. + +- **D-OGAR-CONVERGENCE-SHAPE (operator ruling; 2026-07-05; [G] for the + declaration; per-layer grades in the EPIPHANIES twin):** OGAR is THE + convergence shape across seven layers — schema (the addressed global + schema), ruff codegen (all code = generated projections), classview + fieldmask (one mask → askama context / typed constructor / SQL column + set), OGIT ontology as controller DTO (wire membrane; Auth > RBAC as + mask algebra), lance-graph V3 substrate + classview rows, reasoning + vs controller methods as ONE invocation surface + (`::()` — StepDomain already carries the union; + ERP actions join as one more port), ractor mailbox-kanban as graph + execution (an action executing IS a kanban transition). Canonical + text + per-layer grades + the three open seams (view stratum · + ActionDef↔UnifiedStep mapping · TTL→DTO lowering) + the three + falsifiers (WorkPackage parity witnesses · classview-mask round-trip + · one action end-to-end): `.claude/board/EPIPHANIES.md` + E-OGAR-CONVERGENCE-SHAPE, with companions E-ONE-MASK-THREE-PORTS and + E-MIRROR-EXTERNALIZATION (same arc). Execution receipts feeding the + declaration: ruff `8d6c31b` (schema stratum upstreamed), op-nexgen + `4102eb0` (harvest config), the un-vendor arc (§5 steps 2-3). + +- **D-PARITY-PROBE-WP-1 (falsifier #1 measured; 2026-07-05; [G] — + re-runnable probe, scratchpad parity-probe/):** the WorkPackage parity + witness ran against the hand-written op-work-packages model (18 + fields). Verdict: convergence shape NOT falsified; freeze gate NOT + met pre-fix. Schema stratum + addressing were 100% faithful (18/18 + columns with types + nullability in the IR; classid 0x0102_0001 + decomposing correctly); the entire loss was ONE gate — + project_odoo_fields Python-only, Rails fields dropped at lift + (GAP-1), plus two og_scalar_type rows (string/bigint) and the + unwired not_null→required slot (GAP-3/4) — all closed by the commit + carrying this entry. Post-fix probe tally recorded in that commit's + body. Doctrinal reading (operator-ratified shape): the 8 oracle + fields spelled `_id: Id` are the ORM spelling; the transpiler's + `: ToOne` is the AR spelling the canon keeps — the freeze-gate + metric is AR-shaped domain object vs oracle-minus-FK-spelling; the + `_id` scalars are the STORAGE projection of the classview mask, not + the domain object. Remaining honest 15%: DoneRatio/Formattable + newtypes, the two nullability inversions (id, done_ratio), + lock_version→guard (the ActionDef seam), services. Cross-ref: + E-OGAR-CONVERGENCE-SHAPE falsifier #1, E-ONE-MASK-THREE-PORTS, + E-KEEP-AR-REMOVE-ORM.