diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a36d7d..1b715dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,9 @@ jobs: # gating by feature keeps the default build lean. - name: cargo test -p ogar-adapter-surrealql --features surrealdb-parser run: cargo test -p ogar-adapter-surrealql --features surrealdb-parser + # Exercise the `surrealql-hint` feature on ogar-knowable-from + # — auto-renders the schema_ddl_hint via the adapter's + # emit_surrealql_ddl on register_class_knowable_from. Closes the + # self-describing-registry loop (ADR-023 receipt). + - name: cargo test -p ogar-knowable-from --features surrealql-hint + run: cargo test -p ogar-knowable-from --features surrealql-hint diff --git a/crates/ogar-knowable-from/Cargo.toml b/crates/ogar-knowable-from/Cargo.toml index 3d2f73c..a93640d 100644 --- a/crates/ogar-knowable-from/Cargo.toml +++ b/crates/ogar-knowable-from/Cargo.toml @@ -11,7 +11,14 @@ description = "OGAR-side producer seam for the §10.3 `knowable_from` meet-point [features] default = [] serde = ["dep:serde", "ogar-vocab/serde"] +# Auto-render the `schema_ddl_hint` parameter from the `Class` via +# `ogar-adapter-surrealql::emit_surrealql_ddl(&[class.clone()])` on +# every `register_class_knowable_from` call. Opt-in because the +# adapter pulls the SurrealDB-related dep graph; the default path +# stays lightweight (only `ogar-vocab` + optional `serde`). +surrealql-hint = ["dep:ogar-adapter-surrealql"] [dependencies] ogar-vocab = { path = "../ogar-vocab" } +ogar-adapter-surrealql = { path = "../ogar-adapter-surrealql", optional = true } serde = { workspace = true, optional = true } diff --git a/crates/ogar-knowable-from/src/lib.rs b/crates/ogar-knowable-from/src/lib.rs index 3098192..e81fa32 100644 --- a/crates/ogar-knowable-from/src/lib.rs +++ b/crates/ogar-knowable-from/src/lib.rs @@ -223,6 +223,25 @@ pub trait KnowableFromStore: Send + Sync { /// `NiblePath` identity, same VART-as-reference-backend pattern /// (see crate-level "Reference backends"). /// +/// # `schema_ddl_hint` — the self-describing-registry loop +/// +/// With the **`surrealql-hint` feature ON**, this function renders the +/// SurrealQL DDL for the class (via +/// `ogar-adapter-surrealql::emit_surrealql_ddl`) and passes it to +/// `store.register(class_identity, Some(ddl))`. The registry then +/// carries the producer's view of the class shape alongside the +/// `knowable_from` stamp — *"the registry is self-describing"* per +/// the `KnowableFromStore::register` docstring, no longer aspirational. +/// +/// With the feature OFF (the default), `None` is passed — the +/// lightweight path. The feature is opt-in because the +/// `ogar-adapter-surrealql` dep pulls the SurrealDB AST surface into +/// the build graph. +/// +/// Aligns with ADR-023 (IR-as-wire-truth): the canonical `Class` IR +/// is the wire-truth carrier; the DDL string is its serialization for +/// the registry. See `docs/ARCHITECTURAL-DECISIONS-2026-06-04.md`. +/// /// [1]: https://docs.rs/ogar-ontology pub fn register_class_knowable_from( class: &Class, @@ -241,11 +260,23 @@ pub fn register_class_knowable_from( ogar_ontology::class_identity)".into(), )); } - // v1 minimum-shape: pass None for schema_ddl_hint. Future PRs can - // render via ogar-adapter-surrealql::emit_surrealql_ddl(&[class.clone()]) - // — the `class` parameter is retained for that future expansion. - let _ = class; // keep the parameter live for forward compatibility - store.register(class_identity, None) + // The `schema_ddl_hint` parameter — `None` by default (lightweight + // path), auto-rendered via `ogar-adapter-surrealql::emit_surrealql_ddl` + // when the `surrealql-hint` feature is on. This closes the loop the + // `KnowableFromStore::register` docstring named: the trait carries a + // `schema_ddl_hint: Option<&str>` slot so the registry is + // self-describing; PR #32 landed the producer; this is where it + // gets wired into the call site. + #[cfg(feature = "surrealql-hint")] + { + let ddl = ogar_adapter_surrealql::emit_surrealql_ddl(std::slice::from_ref(class)); + store.register(class_identity, Some(ddl.as_str())) + } + #[cfg(not(feature = "surrealql-hint"))] + { + let _ = class; // keep parameter live; not used in the default path + store.register(class_identity, None) + } } /// Errors from the [`KnowableFromStore`] operations and the @@ -344,7 +375,10 @@ mod tests { let calls = store.register_calls.lock().unwrap(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, "ogit-erp/Account"); - assert!(calls[0].1.is_none(), "v1 minimum-shape passes None for schema_ddl_hint"); + // The schema_ddl_hint axis is feature-gated: dedicated tests + // for each path are below (`default_path_passes_none_for_…` and + // `surrealql_hint_feature_renders_ddl_into_registry`). This + // test stays axis-agnostic on the hint. } #[test] @@ -461,4 +495,57 @@ mod tests { assert!(format!("{b}").contains("backend error")); assert!(format!("{b}").contains("nope")); } + + // ── `schema_ddl_hint` loop closure (ADR-023 / IR-as-wire-truth) ── + // Feature-off (default): `None` is passed to store.register, the + // lightweight path. Feature-on (`surrealql-hint`): the function + // renders the DDL via `ogar-adapter-surrealql::emit_surrealql_ddl` + // and passes it as `Some(&ddl)`. Two tests, conditionally compiled. + // ───────────────────────────────────────────────────────────────── + + #[cfg(not(feature = "surrealql-hint"))] + #[test] + fn default_path_passes_none_for_schema_ddl_hint() { + let c = Class::new("Account"); + let store = MockKnowableFromStore::new(0); + register_class_knowable_from(&c, "ogit-erp/Account", &store).unwrap(); + let calls = store.register_calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + // Default path: the registry receives no DDL hint. + assert!( + calls[0].1.is_none(), + "default (feature-off) path must pass None for schema_ddl_hint, got: {:?}", + calls[0].1 + ); + } + + #[cfg(feature = "surrealql-hint")] + #[test] + fn surrealql_hint_feature_renders_ddl_into_registry() { + // With the `surrealql-hint` feature on, the registry receives + // the SurrealQL DDL rendering of the class — the "self- + // describing registry" claim from KnowableFromStore::register's + // docstring becomes concrete. + let mut c = Class::new("Account"); + let mut email = ogar_vocab::Attribute::new("email"); + email.type_name = Some("string".into()); + c.attributes.push(email); + + let store = MockKnowableFromStore::new(0); + register_class_knowable_from(&c, "ogit-erp/Account", &store).unwrap(); + + let calls = store.register_calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + let hint = calls[0].1.as_deref().expect("feature-on path must populate the hint"); + // The DDL must mention the table and the field — the canonical + // shape `emit_surrealql_ddl` produces. + assert!( + hint.contains("DEFINE TABLE Account SCHEMAFULL;"), + "expected DEFINE TABLE in the hint, got: {hint}" + ); + assert!( + hint.contains("DEFINE FIELD email ON Account TYPE string;"), + "expected DEFINE FIELD in the hint, got: {hint}" + ); + } } diff --git a/docs/ARCHITECTURAL-DECISIONS-2026-06-04.md b/docs/ARCHITECTURAL-DECISIONS-2026-06-04.md index 007df87..8adfd89 100644 --- a/docs/ARCHITECTURAL-DECISIONS-2026-06-04.md +++ b/docs/ARCHITECTURAL-DECISIONS-2026-06-04.md @@ -50,6 +50,7 @@ | ADR-020 | SDK endgame is deeper than Foundry going OSS via three structural differentiators (migration scaffold, self-hosting reference, substrate-layer OSS) | **Pinned** | OGAR PR #20 §5.3 | | ADR-021 | **Meta-hygiene**: always grep peer crates before copying manifest patterns (the `[lints] workspace = true` cascade lesson) | **Pinned** | OGAR PR #15 + PR #17/#18 follow-ups | | ADR-022 | **The Firewall** — absolute inner/outer boundary; no serialization in hot path; inner = compile-time HHTL; outer = contract-trait pluggable | **Pinned** | OGAR (this PR); `docs/THE-FIREWALL.md` | +| ADR-023 | **IR-as-wire-truth** — the source-language AST is *input dialect*; the canonical `Class`/`Attribute`/`Association`/`EnumDecl`/`ActionDef` IR is *wire truth*. Adapters lift dialects into IR; the IR routes everything (registry key, actor mailbox, Lance version, audit-log dimension) | **Pinned** | OGAR (this PR); `crates/ogar-vocab/`; `bardioc/substrate-b-shadow::EdgeDecoder` (PR #19) | ## ADR-001: `State = ActionState` (lifecycle), not domain state, for Rubicon binding @@ -1058,6 +1059,114 @@ designed to produce. - lance-graph PR #470 (`.claude/handovers/2026-06-05-0445-bardioc-to- lance-graph-bindspace-arch-delta.md` — the lance-graph-side pointer). +## ADR-023: IR-as-wire-truth — Class is the wire format, not the source AST + +**Status:** Pinned (2026-06-05). Companion to ADR-022 (The Firewall); +captures the framing principle the firewall's inner side has been +operating under. + +**Context.** Cross-session conversation surfaced the question +*"what's the wire format between source-language frontends and the +substrate?"* — raised in the context of Elixir ASTs, ClickHouse DDL, +SurrealQL DDL, FIBO/FMA TTL, and the planned `ch`/`ecto_ch` shadow +extraction. The naive answer ("forward the source AST as-is") is +wrong; the firewall's inner discipline already implies the right +answer, but it hadn't been named explicitly. + +**Decision.** The canonical wire format is the OGAR IR — `Class`, +`Attribute`, `Association`, `EnumDecl`, `ActionDef`, `KausalSpec`, +`Identity` (the `NiblePath` prefix-radix). Source-language ASTs +(Elixir quoted form, SurrealQL DDL AST, Ruby AR macro tree, Odoo +Python `models.Model` shape, ClickHouse CREATE TABLE, OWL TTL +triples) are *input dialects* — each lifted into the canonical IR +by a dedicated **adapter crate**. Once lifted, everything downstream +(registry key, actor mailbox routing, Lance version stamp, +audit-log dimension, HHTL compile-time codegen) routes through the +*same* IR. + +The aphorism: **"Elixir AST is input dialect; the canonical IR is +wire truth."** Generalizes to any source dialect; the IR is the +shared substrate. + +**Alternatives considered.** + +- *Forward the source AST as the wire format.* Rejected: leaks + source-language syntax + semantics into every downstream consumer; + breaks the firewall's "no serialization in hot path" invariant + (source ASTs are too rich + heterogeneous to be compile-time- + HHTL-resolvable); makes cross-source comparison (e.g. §14 oracle + equivalence-running between OLD-stack Elixir + NEW-stack Rust) + arbitrarily hard because the comparison surface differs per source. +- *Use a "least common denominator" subset of OWL DL.* Rejected: OWL + doesn't model state machines or lifecycle behaviour (the + `ActionDef` + `KausalSpec` axis OGAR adds past OWL DL); the LCD + surface would be insufficient. OGAR sits *above* OWL DL (OWL is + one of OGAR's supported source dialects, not a constraint on the + IR). + +**Consequences.** + +- **Same IR → same hash → same actor routing → same Lance row → + same audit dimension.** Content-addressing primitive. Already + realized in code: `ogar-ontology::class_identity(prefix, name)` + produces the canonical identity string; PR #31 closed the + collision hazard; bardioc PR #19 (`substrate-b-shadow::EdgeDecoder`) + consumes the IR as `ActionInvocation` at the OLD-stack-shadow seam. + +- **Adapters are pluggable, the IR is fixed.** New source dialects + ship as new adapter crates (`ogar-adapter-surrealql`, + `ogar-adapter-ttl` planned, `ogar-from-elixir`, `ogar-from-ecto` + proposed). The `Class` IR doesn't change; only the lift code does. + +- **Round-trip is the adapter contract.** `parse_` → + `Vec` → `emit_` should reproduce the source. OGAR + PR #32 demonstrated this for SurrealQL DDL; round-trip tests + are now part of the adapter contract. + +- **The `schema_ddl_hint` loop closes here.** PR #25 introduced + `KnowableFromStore::register(class_identity, schema_ddl_hint: + Option<&str>)` with the docstring claim *"so the registry is + self-describing"*. PR #32 landed `emit_surrealql_ddl`. This PR + wires the two together (feature-gated `surrealql-hint`): the + registry now carries the producer's `Class` IR projected into + SurrealQL DDL alongside the `knowable_from` stamp. The IR-as-wire- + truth claim is no longer aspirational. + +- **Cross-session triangulation receipt.** bardioc PR #19 + (`substrate-b-shadow`) consumes `ogar-vocab` as a direct + dependency — its `EdgeDecoder` trait IS the IR-as-wire-truth + pattern in code. The HIRO-Graph + ClickHouse decoders return + `ActionInvocation` regardless of source; the rest of the substrate + consumes one shape. + +**Change policy.** Adding a new source dialect (new adapter crate) +is routine. Changing the IR — adding a field to `Class`, +`AssociationKind`, `EnumSource`, `KausalSpec` — is a substrate-wide +contract change requiring (a) backward-compatible default (typically +`Option<…>` field), (b) round-trip preservation in all adapter +crates, (c) consultation with the runtime session (bardioc / +lance-graph) before merge. + +**References.** + +- `crates/ogar-vocab/` — the canonical IR. +- `crates/ogar-ontology/` — identity routing + canonical-form helpers. +- `crates/ogar-knowable-from/` — the registry seam; this PR wires + the `schema_ddl_hint` loop via the `surrealql-hint` feature. +- `crates/ogar-adapter-surrealql/` — first round-trip adapter (PR + #24 wired the parser; PR #32 closed the walk + round-trip). +- `crates/ogar-from-elixir/` — Elixir SchemaSource scaffold. +- ADR-022 (The Firewall) — the invariant ADR-023 makes explicit. +- ADR-016 (SurrealQL DDL AST is not the universal IR) — the + predecessor; ADR-023 generalizes ADR-016's claim from SurrealQL + to *all* source dialects. +- bardioc PR #17 (Rubicon Phases 1-5) — consumer of `ogar-vocab` + for actor dispatch. +- bardioc PR #19 (`substrate-b-shadow::EdgeDecoder`) — the + pattern materialized in runtime-side code. +- `docs/RDF-OWL-ALIGNMENT.md` §3 (OGAR's position in L1-L5) — the + IR sits at the AR-pattern lift seam. + ## Implementation receipts — ADR ↔ commit cross-reference > **Added in follow-up addendum (2026-06-05).** Records the implementation