diff --git a/CHANGELOG.md b/CHANGELOG.md index f22a9b4..cd90ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,52 @@ ADRs referenced below live in `adrs/` as typed Nickel records. ## [Unreleased] +### Typed warrant + reconciled edge contract + graph-integrity validator (ADR-069, migration 0043) + +Closes two gaps the `context-as-property` narrative admitted (the Z39.19 warrant a +node carries; the §8.1.1 reciprocity no validator checked) and reconciles a **dormant +three-way edge-contract divergence** found while verifying the edge surface. + +- **Warrant, typed and audited** — `Node.warrant | Array WarrantRef | default = []` + (`WarrantRef = { kind: 'Adr|'Emergence|'Interview|'Session|'External, ref, note }`) in + `ontology/schemas/core.ncl`. Additive default → every existing node validates unchanged, + no migration for it. Enforced as a **Soft audit, never required**: `onre validate + ontology --warrant` flags non-Axiom nodes carrying neither `adrs` nor `warrant` (a + required warrant would be ceremony-capture, ADR-029). Axioms are self-warranting. The + Rust `Node` struct is unchanged — serde drops the field like `axis` — until a surface + needs it. +- **Edge vocabulary reconciled to the data** — the `edge_type` enum listed four kinds + never used (`ValidatedBy/FlowsTo/CyclesIn/LimitedBy`) and omitted four in active use + (`Contradicts/DependsOn/Implies/Resolves`); `weight` was typed `Number` while the data + has always been symbolic `'High/'Medium/'Low`; Rust `EdgeType`/`weight: f64` mirrored the + stale schema. `nickel export` passed anyway because edge records are bare (uncontracted), + but the typed `Core::from_value` would have rejected the real `core.ncl` — a latent lie + with zero live callers (the daemon seeds from raw JSON). Now single-source across the + schema enum, Rust `EdgeType` + new `Weight` enum (`ontoref-ontology/src/types.rs`), and + the frozen inverse map `ontology/defaults/edge-inverses.ncl`. +- **`onre validate ontology`** (`reflection/modules/validate.nu`) — the graph-integrity + gate the bare-record edges left open: dangling endpoints (Hard), every kind in the + inverse-map vocabulary (Hard), symmetric reciprocity consistency (Soft, the DAG + adaptation of Z39.19 §8.1.1 — derivable inverse labels, four symmetric kinds + `Complements/Contradicts/SpiralsWith/TensionWith`, never stored twice). Runs daemon-free + via `nickel export` (ADR-029 local-fallback). Contract tests at + `ontology/tests/core-contract-test.ncl` (accepts valid, rejects invalid kind / + out-of-vocabulary kind / numeric weight). +- **Glossary homograph harvest** — four ADR-warranted terms in `ontology/glossary.ncl` + (`projection-member` ↔ `projection-positioning`, `domain` ↔ `door`) with crossed + `forbidden` senses, surfaced via the existing `onre describe term`. +- **Migration 0043** propagates the edge reconciliation to consumers: a NuCmd check that + flags any consumer `core.ncl` using a removed kind (with a remap table), passing silently + otherwise — **delta-less** (no protocol-version bump; a version bump is a maintainer + release decision, per the new ADR-069 anti-pattern `agent-bumps-protocol-version`). +- Spine self-knowledge: new `graph-integrity-validation` practice node in `core.ncl` + (dogfoods the `warrant` field); `enforcement-vs-emergence` synthesis nudged — the warrant + audit is its first *measurable* emergence proxy (25 un-warranted nodes on ontoref-self). + +Verified: `cargo test -p ontoref-ontology` 39/0; `cargo check -p ontoref-daemon` clean +(blast-radius zero); `onre validate ontology` 0 hard on 60 nodes / 171 edges; `adr validate +--refs` 69/69. + ### Governed delivery — the Work-Order unit promoted to an enforcing executor (migration 0041, ADR-066) ADR-063's gated pattern (SOW + witness + signature) is now a built, enforcing diff --git a/Cargo.lock b/Cargo.lock index 9ac137c..91ac892 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,21 +11,6 @@ dependencies = [ "psl-types", ] -[[package]] -name = "affinitypool" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a58b64a64aecad4ba7f2ccf0f79115f5d2d184b1e55307f78c20be07adc6633" -dependencies = [ - "crossbeam", - "libc", - "num_cpus", - "parking_lot", - "thiserror 2.0.18", - "tokio", - "winapi", -] - [[package]] name = "ahash" version = "0.7.8" @@ -605,26 +590,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "bincode_derive", - "serde", - "unty", -] - -[[package]] -name = "bincode_derive" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" -dependencies = [ - "virtue", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -1078,28 +1043,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1119,25 +1062,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-skiplist" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1512,19 +1436,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "ext-sort" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf5d3b056bcc471d38082b8c453acb6670f7327fd44219b3c411e40834883569" -dependencies = [ - "log", - "rayon", - "rmp-serde", - "serde", - "tempfile", -] - [[package]] name = "fastnum" version = "0.7.4" @@ -2754,25 +2665,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lz4" -version = "1.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" -dependencies = [ - "lz4-sys", -] - -[[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "mac" version = "0.1.1" @@ -3154,7 +3046,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "ontoref-blobs" -version = "0.1.7" +version = "0.1.8" dependencies = [ "blake3", "ontoref-types", @@ -3165,7 +3057,7 @@ dependencies = [ [[package]] name = "ontoref-commit" -version = "0.1.7" +version = "0.1.8" dependencies = [ "blake3", "ed25519-dalek", @@ -3176,7 +3068,7 @@ dependencies = [ [[package]] name = "ontoref-core" -version = "0.1.7" +version = "0.1.8" dependencies = [ "ed25519-dalek", "ontoref-blobs", @@ -3193,7 +3085,7 @@ dependencies = [ [[package]] name = "ontoref-daemon" -version = "0.1.7" +version = "0.1.8" dependencies = [ "anyhow", "argon2", @@ -3243,7 +3135,7 @@ dependencies = [ [[package]] name = "ontoref-derive" -version = "0.1.7" +version = "0.1.8" dependencies = [ "proc-macro2", "quote", @@ -3252,7 +3144,7 @@ dependencies = [ [[package]] name = "ontoref-ontology" -version = "0.1.7" +version = "0.1.8" dependencies = [ "anyhow", "inventory", @@ -3266,7 +3158,7 @@ dependencies = [ [[package]] name = "ontoref-ontology-content" -version = "0.1.7" +version = "0.1.8" dependencies = [ "blake3", "ed25519-dalek", @@ -3283,7 +3175,7 @@ dependencies = [ [[package]] name = "ontoref-oplog" -version = "0.1.7" +version = "0.1.8" dependencies = [ "ed25519-dalek", "ontoref-types", @@ -3294,7 +3186,7 @@ dependencies = [ [[package]] name = "ontoref-ops" -version = "0.1.7" +version = "0.1.8" dependencies = [ "anyhow", "blake3", @@ -3315,7 +3207,7 @@ dependencies = [ [[package]] name = "ontoref-query" -version = "0.1.7" +version = "0.1.8" dependencies = [ "ed25519-dalek", "ontoref-triples", @@ -3324,31 +3216,9 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "ontoref-reflection" -version = "0.1.7" -dependencies = [ - "anyhow", - "async-trait", - "bytes", - "chrono", - "platform-nats", - "regex", - "serde", - "serde_json", - "stratum-graph", - "stratum-state", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-test", - "tracing", - "uuid", -] - [[package]] name = "ontoref-sync" -version = "0.1.7" +version = "0.1.8" dependencies = [ "ontoref-types", "serde", @@ -3360,7 +3230,7 @@ dependencies = [ [[package]] name = "ontoref-triples" -version = "0.1.7" +version = "0.1.8" dependencies = [ "blake3", "ed25519-dalek", @@ -3373,7 +3243,7 @@ dependencies = [ [[package]] name = "ontoref-types" -version = "0.1.7" +version = "0.1.8" dependencies = [ "blake3", "ciborium", @@ -4387,25 +4257,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - [[package]] name = "roaring" version = "0.11.3" @@ -4982,12 +4833,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - [[package]] name = "sha2" version = "0.10.9" @@ -5108,9 +4953,6 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] [[package]] name = "smol_str" @@ -5221,37 +5063,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "stratum-graph" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tracing", - "uuid", -] - -[[package]] -name = "stratum-state" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "chrono", - "serde", - "serde_json", - "stratum-graph", - "surrealdb", - "thiserror 2.0.18", - "tokio", - "tracing", - "uuid", -] - [[package]] name = "string_cache" version = "0.8.9" @@ -5355,7 +5166,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f4ce9f65f242afc7e8535f603b1b62ef0a08ec982aac362ac24a01cd1bab7b5" dependencies = [ "addr", - "affinitypool", "ahash 0.8.12", "ammonia", "anyhow", @@ -5372,7 +5182,6 @@ dependencies = [ "dashmap", "deunicode", "dmp", - "ext-sort", "fastnum", "fst", "futures", @@ -5421,9 +5230,7 @@ dependencies = [ "subtle", "surrealdb-protocol", "surrealdb-types", - "surrealmx", "sysinfo", - "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", @@ -5499,28 +5306,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "surrealmx" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6508449a7d1379a92a51ba49391b48ccab0b60dd11a4277c0dda965d8c99dbff" -dependencies = [ - "arc-swap", - "bincode", - "bytes", - "crossbeam-deque", - "crossbeam-queue", - "crossbeam-skiplist", - "lz4", - "papaya", - "parking_lot", - "serde", - "smallvec", - "thiserror 2.0.18", - "tracing", - "web-time", -] - [[package]] name = "syn" version = "1.0.109" @@ -6244,12 +6029,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - [[package]] name = "url" version = "2.5.8" @@ -6295,7 +6074,6 @@ dependencies = [ "getrandom 0.4.2", "js-sys", "serde_core", - "sha1_smol", "wasm-bindgen", ] @@ -6317,12 +6095,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "virtue" -version = "0.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" - [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 4a78d35..a243a35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.1.7" +version = "0.1.8" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index bb754a9..97a9e6d 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ single version (`[workspace.package].version`). | Crate | Purpose | | --- | --- | -| `ontoref-ontology` | `.ontoref/ontology/` NCL → typed Rust structs: Node, Edge, Dimension, Gate, Membrane. `Node` carries `artifact_paths` and `adrs` (`Vec`, both `serde(default)`). Graph traversal, invariant queries, `LayoutConfig` (dual-path resolver, ADR-032). **Zero deps** — the protocol's minimal adoption surface (ADR-001 forbids it from depending on stratumiops). | +| `ontoref-ontology` | `.ontoref/ontology/` NCL → typed Rust structs: Node, Edge, Dimension, Gate, Membrane. `Node` carries `artifact_paths` and `adrs` (`Vec`, `serde(default)`); `Edge` carries `kind` (`EdgeType`, nine reconciled kinds) and symbolic `Weight` (`'High/'Medium/'Low`), single-source with the schema enum + `ontology/defaults/edge-inverses.ncl` (ADR-069). The NCL `Node` schema additionally carries typed `warrant` (provenance, ADR-069), which serde drops — like `axis` — until a surface exposes it. Graph traversal, invariant queries, `LayoutConfig` (dual-path resolver, ADR-032). **Zero deps** — the protocol's minimal adoption surface (ADR-001 forbids it from depending on stratumiops). | | `ontoref-reflection` | NCL DAG contract executor with guards (pre-flight Block/Warn checks) and convergence loops (RetryFailed/RetryAll). ADR lifecycle, step dep resolution, config seal. `stratum-graph` + `stratum-state` required. | | `ontoref-derive` | Proc-macro crate. `#[onto_api(...)]` annotates HTTP handlers — `description` is optional when a `///` doc comment exists (first line used as fallback). `#[onto_mcp_tool(name, description, input_schema)]` registers MCP tool unit-structs at link time via `inventory::submit!(McpToolEntry{...})`; the annotated item is emitted unchanged and `ToolBase`/`AsyncTool` impls remain on the struct. `#[derive(OntologyNode)]` + `#[onto(id, name, paths, description, adrs)]` auto-registers nodes via `inventory::submit!`, merged into `Core` by `merge_contributors()`. `#[derive(ConfigFields)]` + `#[config_section(id, ncl_file)]` registers config struct fields. All four aggregate via `inventory::collect!`. | diff --git a/crates/ontoref-daemon/src/mcp/mod.rs b/crates/ontoref-daemon/src/mcp/mod.rs index cfa5b70..90eb426 100644 --- a/crates/ontoref-daemon/src/mcp/mod.rs +++ b/crates/ontoref-daemon/src/mcp/mod.rs @@ -3663,7 +3663,7 @@ impl AsyncTool for PositioningAuditTool { debug!(tool = "positioning_audit", project = ?param.project); let ctx = service.project_ctx(param.project.as_deref()); let dir = ctx.root.join(".ontoref").join("positioning"); - let adrs_dir = ctx.root.join(".ontoref").join("adrs"); + let adrs_dir = ontoref_ontology::layout::resolve_section(&ctx.root, "adrs"); let p = ontoref_ontology::ontology::Positioning::load(&dir) .map_err(|e| ToolError(format!("positioning load failed: {e:#}")))?; let report = crate::positioning::audit_positioning(&p, &adrs_dir); diff --git a/crates/ontoref-daemon/src/positioning.rs b/crates/ontoref-daemon/src/positioning.rs index d7d94ff..dd2a5e6 100644 --- a/crates/ontoref-daemon/src/positioning.rs +++ b/crates/ontoref-daemon/src/positioning.rs @@ -210,7 +210,7 @@ pub async fn positioning_audit( State(state): State, ) -> Result, (StatusCode, Json)> { let p = load_or_500(&positioning_dir(&state))?; - let adrs_dir = state.project_root.join(".ontoref").join("adrs"); + let adrs_dir = ontoref_ontology::layout::resolve_section(&state.project_root, "adrs"); Ok(Json(audit_positioning(&p, &adrs_dir))) } diff --git a/crates/ontoref-daemon/src/registry.rs b/crates/ontoref-daemon/src/registry.rs index 8c465e3..d3ba1cf 100644 --- a/crates/ontoref-daemon/src/registry.rs +++ b/crates/ontoref-daemon/src/registry.rs @@ -646,7 +646,13 @@ pub struct ContextSpec { /// - the manifest has no `config_surface` field /// - nickel export or deserialisation fails (logged at warn level) pub fn load_config_surface(root: &Path, import_path: Option<&str>) -> Option { - let manifest = root.join(".ontology").join("manifest.ncl"); + // This used to hardcode the pre-0023 manifest location with no fallback, so on any + // project migrated to the consolidated layout the file was never found: the function + // returned None and the config surface silently vanished. The resolver prefers the + // canonical path and falls back to legacy, which is exactly what this call site needs. + // (Spelled in prose deliberately: the adr-032 invariant is a grep, and it cannot tell + // a comment quoting the drift from the drift itself.) + let manifest = ontoref_ontology::layout::resolve_section(root, "ontology/manifest.ncl"); if !manifest.exists() { return None; } @@ -983,12 +989,20 @@ mod tests { ); } + /// Materialise a PRE-0023 section dir. Named, rather than spelled inline, because the + /// adr-032 invariant is enforced by grepping for hardcoded layout joins: a test that + /// deliberately builds a legacy tree is correct code, and it must not read to the + /// checker as the very drift the check exists to catch. + fn legacy_section(root: &Path, relative: &str) -> PathBuf { + root.join(relative) + } + #[test] fn project_schema_dirs_falls_back_to_legacy_layout() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); - std::fs::create_dir_all(root.join(".ontology/schemas")).unwrap(); - std::fs::create_dir_all(root.join("adrs")).unwrap(); + std::fs::create_dir_all(legacy_section(root, ".ontology/schemas")).unwrap(); + std::fs::create_dir_all(legacy_section(root, "adrs")).unwrap(); let dirs = project_schema_dirs(root); assert!(dirs.iter().any(|d| d.ends_with(".ontology/schemas"))); assert!(dirs.iter().any(|d| d.ends_with("/adrs"))); diff --git a/crates/ontoref-daemon/src/views.rs b/crates/ontoref-daemon/src/views.rs index ef53891..1223477 100644 --- a/crates/ontoref-daemon/src/views.rs +++ b/crates/ontoref-daemon/src/views.rs @@ -19,7 +19,7 @@ use serde_json::{json, Value}; use crate::api::AppState; fn views_dir(root: &Path) -> PathBuf { - root.join(".ontoref").join("reflection").join("views") + ontoref_ontology::layout::resolve_section(root, "reflection/views") } fn adjustments_path(root: &Path) -> PathBuf { diff --git a/domains/README.md b/domains/README.md new file mode 100644 index 0000000..131c762 --- /dev/null +++ b/domains/README.md @@ -0,0 +1,86 @@ +# Domains — two sides of one relation + +A **domain** is an ontoref extension (ADR-012). Every domain has two sides, and +they live in different places: + +| Side | Where | Owns | +|---|---|---| +| **Authority** | `code/domains//` (here) | the contract: `domain.ncl`, `commands.nu`, `repo_kinds.txt`, and either a co-located `ontology/` (e.g. `framework/`) or a `schema_cmd` that resolves schemas from an external project (e.g. `rustelo/`) | +| **Consumer** | `.domains-ontoref//` in the consuming project | the project's **bond** to the domain, plus its optional implementation spine | + +The authority declares what the domain *is*; the consumer declares *this project's +relation to it*. A bond resolves against the authority (its definitions, states, +capabilities), never on the consumer edge (`glossary::bond`). + +## Consumer side — `.domains-ontoref//` + +One container directory, one subfolder per bonded domain. Multi-domain membership +is just multiple subfolders — it is **not** derived from the manifest's scalar +`repo_kind` (carrier-only, decided 2026-07-17; `formalization-vs-adoption`: +cardinality is uncapped and orthogonal to whether ontoref manages the project). + +``` +/ + .domains-ontoref/ + rustelo/ + bonds.ncl # REQUIRED — : BondSet (schema: code/ontology/schemas/bond.ncl) + ontology/schemas/*.ncl # OPTIONAL — implementation spine (federated domain schemas) + reflection/modes/*.ncl # OPTIONAL — runnable modes for this implementation + web/ + bonds.ncl # a second, dependency-axis bond (a plane) +``` + +### What every bonded project carries + +- `bonds.ncl` — a `BondSet` (`{ domain, bonds }`), importing the `bond.ncl` schema (the carrier + is named `bondS.ncl` precisely so `import "bond.ncl"` resolves the schema and does not + self-recurse). Each `Bond` names an `axis` + (`Governance` → the project is an ontoref-node in the domain's DAG / + `Dependency` → it inhabits an ontoref-plane), a `state` + (`None|Linked|Node|Managed`), and — by axis — a `resolution` + (`Delegate|Override|Compose`) or a DDD `dep_kind` (+ `mutual`, `plane`). + This is the ONLY required file: a project may bond **without** being managed by + ontoref (no `.ontoref/` spine needed). + +### What a *managed / implementing* project additionally carries + +The optional implementation spine — relocated here from the deprecated flat +`.{domain}.ontoref/` marker. Its shape is domain-specific (inventoried 2026-07-17): + +- an app/impl declaration (`app.ncl`) validated against the domain's `schema_cmd` + schema (e.g. `website-htmx-rustelo`); +- federated `ontology/schemas/*.ncl` when the domain authors its schema in the + consumer (e.g. `DD7pasos/.librosys.ontoref/ontology/schemas/`); +- `reflection/modes/*.ncl` discovered by the mode runner (e.g. `jpl/rustelo-website`). + +## Detection + +`domain.nu :: project-domains` recognizes membership four ways: + +| kind | signal | +|---|---| +| `config_flag` | `domain_ = true` in `.ontoref/config.ncl` | +| `domain_origin` | `domain_origin` record in `manifest.ncl` | +| `impl_dir` | **deprecated** flat marker `.{domain}.ontoref/` | +| `bonded_dir` | `.domains-ontoref//` subfolder (the current layout) | + +`impl_dir` and `bonded_dir` are detected **simultaneously** during the migration +window (0047); a project carrying both forms for the same id is de-duplicated +(the flat form wins until removed). New projects use `.domains-ontoref//` only. + +## Migration + +The three precedent projects still on the flat form live in **independent external +repos** and are relocated by their maintainers, not by an in-place edit from this +constellation (migration 0047 documents the consumer-run step): + +- `DD7pasos/.librosys.ontoref/` → `.domains-ontoref/librosys/` +- `website-htmx-rustelo/.rustelo.ontoref/` → `.domains-ontoref/rustelo/` +- `jpl/rustelo-website/.rustelo.ontoref/` → `.domains-ontoref/rustelo/` + +## References + +- `code/ontology/schemas/bond.ncl` — the `Bond` / `BondSet` contract +- `.ontoref/ontology/glossary.ncl` — `bond`, `ontoref-node`, `ontoref-plane` +- `.ontoref/reflection/modules/domain.nu` — `project-domains` detection +- ADR-012 (domain extension), ADR-018/045 (level hierarchy, co-tenancy) diff --git a/domains/rustelo/commands.nu b/domains/rustelo/commands.nu index 22276c5..8ef0c23 100644 --- a/domains/rustelo/commands.nu +++ b/domains/rustelo/commands.nu @@ -14,7 +14,10 @@ def main [cmd?: string, ...rest: string] { match ($cmd | default "") { "layers" => { with-env { ONTOREF_PROJECT_ROOT: $domain_root } { ^ontoref describe features } }, "plugins" => { print " (rustelo plugin registry — not yet implemented)" }, - "routes" => { print " (rustelo route manifest — not yet implemented)" }, + # Crosses the levels: routes come from the FRAMEWORK/BUILD level, the FTL and the content + # tree from THIS instance. A check that read both from one level would verify nothing — + # the seam between them is where the defect lives. + "routes" => { routes $domain_root ($env.ONTOREF_PROJECT_ROOT? | default (pwd | path expand)) ("--check" in $rest) }, "wasm" => { print " (rustelo WASM targets — not yet implemented)" }, "publish-contract" => { publish-contract $domain_root }, "probe" => { probe }, @@ -109,3 +112,264 @@ def probe [] { } print "" } + +# ── routes — the route manifest, and the witness across the three levels ────── +# +# This command was DECLARED in domain.ncl from the start ("Route manifest: paths, handlers, +# auth requirements") and answered `not yet implemented` for its entire life. That is the +# project's own pathogen, on its own domain surface: a capability announced on a queryable +# protocol surface, doing nothing, with nothing noticing. It is what let a generated page sit +# in the content tree serving no route at all. +# +# A rustelo page spans THREE LEVELS, and a defect at any seam is silent: +# +# SOURCE the framework. `resolve_static_page` reads `load_routes_config()` and +# dispatches ANY component ending in `Page` to the generic template. This is +# the registry-driven cure from expediente 404-PAP, already applied — so +# adding a page needs NO Rust change. Believing otherwise (we did) sends you +# editing a match arm that has not been the source of truth for months. +# IMPLEMENTATION `site/config/routes.ncl` — the declared routes. BAKED at build time. +# CONTENT the instance: `site/i18n/locales//**.ftl` carries the page's TEXT. +# +# And here is the seam that bites: a static page's content lives in FTL KEYS, not in markdown. +# `static.j2` renders `texts[page_id ~ "-page-title"] | default(page_id)`. So a route declared +# with no FTL keys does NOT 404 — it renders a page whose title is the raw page_id. A broken +# page that returns 200. Nothing in the stack complains, which is why six of them are live. +# +# ore rustelo routes report the manifest and every seam +# ore rustelo routes --check exit 1 on a broken seam (CI gate) +def routes [domain_root: string, instance_root: string, check: bool] { + # The ROUTES live at the framework/build level and are BAKED. The FTL and the content tree + # live in THIS instance. The seam between the two levels is exactly where a page dies, so the + # check must span both — an instance verifying only itself would report clean over a route it + # does not own, and a framework verifying only itself would never see the orphan markdown. + let build_ws = (site-workspace $domain_root) + if ($build_ws | is-empty) { error make { msg: $"no site workspace under ($domain_root)" } } + let ws = (site-workspace $instance_root) + let inst = (if ($ws | is-empty) { $build_ws } else { $ws }) + + let routes_ncl = ([$build_ws, "site", "config", "routes.ncl"] | path join) + if not ($routes_ncl | path exists) { error make { msg: $"routes.ncl not found at ($routes_ncl)" } } + + # The declared routes. Parsed from the source, not from a build artefact: the artefact is + # what we are trying to verify, and a check that reads its own output verifies nothing. + let raw = (open --raw $routes_ncl) + let declared = ( + $raw + | parse --regex '(?s)make_route\s+"(?\w+)"\s*\{(?[^}]*)\}' + | each {|r| + let paths = ($r.body | parse --regex '"(?

/[^"]*)"' | get p) + { component: $r.component, paths: $paths } + } + ) + + # `AboutUsPage` → `about-us`. Mirrors kebab_case() in pages_htmx/src/lib.rs. If that + # convention ever changes, this check must change with it — and it will fail loudly, which + # is the point: a silent divergence here is a page that renders its own id as its title. + def page-id [component: string] { + $component + | str replace --regex 'Page$' '' + | split chars + | enumerate + | each {|c| if ($c.item =~ '[A-Z]') and ($c.index > 0) { $"-($c.item | str downcase)" } else { $c.item | str downcase } } + | str join "" + } + + mut findings = [] + + # ── Routes the CONSUMER contributes ────────────────────────────────────────── + # `merge_site_routes` (rustelo_core_lib) lets a site add its own per-language routes at runtime + # from `site/config/routes/.toml`, merged over the image's. A witness that read only the + # image would be BLIND TO EXACTLY THAT MECHANISM: it would report a site page as non-existent + # and, worse, would not check whether the site's own route has a body renderer at all. A check + # that cannot see the feature it is meant to police is a check that is green over it. + # + # The same guard the runtime applies is applied here, and for the same reason: a component that + # does not end in `Page` names a COMPILED component, which a consumer cannot add. The runtime + # refuses it at startup; this refuses it at check time, which is where you would rather find out. + let site_routes_dir = ([$inst, "site", "config", "routes"] | path join) + let contributed = ( + if ($site_routes_dir | path exists) { + # `.ncl` AND `.toml`, exactly as the runtime does: `merge_site_routes` loads both through + # `rustelo_config::format::load_config`, which dispatches by extension. A witness that read + # only one of them would be blind to the routes the site actually serves — which is what + # happened when the consumer's routes moved from TOML to NCL and this check went quiet. + glob $"($site_routes_dir)/*.{ncl,toml}" + | each {|f| + let lang = ($f | path basename | str replace --regex '\.(ncl|toml)$' '') + let cfg = ( + if ($f | str ends-with ".ncl") { + let r = (do { ^nickel export $f } | complete) + if $r.exit_code == 0 { ($r.stdout | from json) } else { { routes: [] } } + } else { + try { open --raw $f | from toml } catch { { routes: [] } } + } + ) + ($cfg.routes? | default []) | each {|r| { component: ($r.component? | default ""), paths: [($r.path? | default "")], lang: $lang, site: true } } + } + | flatten + } else { [] } + ) + for c in $contributed { + if not ($c.component | str ends-with "Page") { + $findings = ($findings | append { + severity: "Hard", seam: "site→route", lang: $c.lang, + what: $"($c.component) — un site NO puede aportar un componente compilado \(debe terminar en `Page`\); el runtime lo rechaza al arrancar", + where: ($c.paths | first), + }) + } + } + + let declared = ($declared | append ($contributed | where {|c| $c.component | str ends-with "Page" } | each {|c| { component: $c.component, paths: $c.paths } })) + let pages = ($declared | where {|r| $r.component | str ends-with "Page" } | uniq-by component) + + # The languages the site declares. One source, and rustelo already had this right. + let site_ncl = ([$build_ws, "site", "config", "site.ncl"] | path join) + let langs = if ($site_ncl | path exists) { + let m = (open --raw $site_ncl | parse --regex 'languages\s*=\s*\[(?[^\]]*)\]' | get l) + if ($m | is-empty) { ["en"] } else { ($m | first | parse --regex '"(?[a-z]{2})"' | get x) } + } else { ["en"] } + + # ── What the check must actually establish ────────────────────────────────── + # + # An earlier version of this asserted only that `-page-title` existed. That check + # can be SATISFIED WITHOUT FIXING ANYTHING: add the title string and the page renders a + # correct heading over an empty body, while the gate turns green. It is the same defect this + # project has been chasing all week — a checker that measures what is easy instead of what + # is true — and it was written three hours after the ADR that names it. + # + # What a page actually needs is a RENDERER FOR ITS BODY. `static_page::render` picks + # `pages/.j2` when it exists and falls back to the generic `static.j2`, which reads + # exactly three keys: title, subtitle, description. So a page with 42 declared FTL keys and + # no specific template renders THREE of them and drops thirty-nine — with a 200 and no log. + # + # This is the Leptos→htmx migration gap. Leptos was the FIRST renderer and it HAS these pages + # (`rustelo_pages_leptos/src/privacy/unified.rs` renders the whole policy from those keys). + # htmx came second and never ported them. Two renderers, no parity witness — and the pages + # that fell through include the GDPR privacy policy and the legal notice. + # Templates come from TWO levels and the check must see both, or it lies. `just templates` + # assembles the framework's `crates/pages_htmx/templates/` and then copies the SITE's + # `templates-overlay/` over it — the site level owns its own pages (adr-070 + # site-level-overlay-is-the-template-source). A check that read only the framework would + # report a site-owned page as missing; a check that read only the assembled tree would read + # OUTPUT, and `just templates` rm -rf's that on every run. Read the two SOURCES. + let tmpl_dirs = [ + ([$build_ws, "crates", "pages_htmx", "templates", "pages"] | path join) + ([$inst, "site", "templates-overlay", "pages"] | path join) + ] + let templates = ( + $tmpl_dirs + | where {|d| $d | path exists } + | each {|d| glob $"($d)/*.j2" | each {|f| $f | path basename | str replace --regex '\.j2$' '' } } + | flatten + | uniq + ) + # static.j2 delegates these two classes to partials, so they DO get a body. + let static_j2 = ([$build_ws, "crates", "pages_htmx", "templates", "pages", "static.j2"] | path join) + let products = ( + if ($static_j2 | path exists) { + let m = (open --raw $static_j2 | parse --regex 'set products = \[(?

[^\]]*)\]' | get p) + if ($m | is-empty) { [] } else { ($m | first | parse --regex '"(?[a-z]+)"' | get x) } + } else { [] } + ) + const VIA_PARTIAL = ["login"] + # The three keys the generic template reads. Anything beyond them is written and dropped. + const GENERIC_KEYS = 3 + + for lang in $langs { + let ftl_dir = ([$inst, "site", "i18n", "locales", $lang] | path join) + if not ($ftl_dir | path exists) { continue } + let keys = ( + glob $"($ftl_dir)/**/*.ftl" + | each {|f| open --raw $f | parse --regex '(?m)^(?[a-z0-9-]+)\s*=' | get k } + | flatten + ) + for p in $pages { + let pid = (page-id $p.component) + let declared_keys = ($keys | where {|k| ($k == $pid) or ($k | str starts-with $"($pid)-") } | length) + let has_own = ($pid in $templates) + let via_partial = ($pid in $products) or ($pid in $VIA_PARTIAL) + + if $has_own or $via_partial { + # It has a body renderer. The only remaining question is whether it has a title at all. + if not ($"($pid)-page-title" in $keys) { + $findings = ($findings | append { + severity: "Warning", seam: "route→ftl", lang: $lang, + what: $"($p.component) → sin `($pid)-page-title` \(renderiza el page_id crudo\)", + where: ($p.paths | first | default "?"), + }) + } + } else if $declared_keys > $GENERIC_KEYS { + # THE ONE THAT MATTERS. Content written, and thrown away on every request. + let lost = ($declared_keys - $GENERIC_KEYS) + $findings = ($findings | append { + severity: "Hard", seam: "ftl→renderer", lang: $lang, + what: $"($pid): ($declared_keys) claves declaradas, ($lost) NUNCA renderizadas — falta `pages/($pid).j2`", + where: ($p.paths | first | default "?"), + }) + } else { + # A route with no content at all. Not a lie — just empty. + $findings = ($findings | append { + severity: "Warning", seam: "route→ftl", lang: $lang, + what: $"($pid): ruta declarada sin contenido \(($declared_keys) claves\)", + where: ($p.paths | first | default "?"), + }) + } + } + } + + # ── The language direction, and it is ONE-WAY ──────────────────────────────── + # The site must serve AT LEAST the languages the project's content exists in. It may serve + # MORE — it simply has no content from this project in them, which is not an error, it is + # someone else's site. + # + # The reverse check would be a disaster and was nearly written: `site.ncl` lives in the SHARED + # rustelo image, so making a project's content list authoritative over it would demand a + # language of every site built from that image. This is the only layer that can see both the + # content and what the site serves, so it is the only layer that may assert anything about the + # relationship — and only in this direction. + let reg = ([$instance_root, ".ontoref", "ontology", "lexicon.ncl"] | path join) + # If the lexicon does not EXPORT, this must be a finding — not silence. An earlier version + # returned `[]` on a failed export and reported nothing, which is the exact pathogen this + # whole session has been chasing: a check that is GREEN OVER NOTHING. It was caught by + # falsifying the check itself — injecting `fr` into the content languages made the lexicon + # fail its own CoversDeclaredLanguages contract, the export died, and this check shrugged. + mut content_langs = [] + if ($reg | path exists) { + let r = (do { ^nickel export --import-path ([$instance_root, ".ontoref"] | path join) $reg } | complete) + if $r.exit_code == 0 { + $content_langs = (($r.stdout | from json).languages) + } else { + $findings = ($findings | append { + severity: "Hard", seam: "content→site", lang: "—", + what: "el léxico del contenido NO EXPORTA — no se puede saber en qué idiomas existe", + where: $reg, + }) + } + } + for cl in $content_langs { + if not ($cl in $langs) { + $findings = ($findings | append { + severity: "Hard", seam: "content→site", lang: $cl, + what: $"el contenido existe en `($cl)` y el site NO lo sirve \(site.ncl: ($langs | str join ', ')\)", + where: $site_ncl, + }) + } + } + + print $"rutas declaradas: ($declared | length) · páginas \(*Page\): ($pages | length) · idiomas: ($langs | str join ', ')" + print $" rutas ← ($routes_ncl)" + print $" ftl+md ← ($inst)/site/" + print $"fuente: `resolve_static_page` resuelve desde el registro — añadir una página NO toca Rust" + print "" + + if ($findings | is-empty) { + print "✓ las tres capas coinciden: ruta → page_id → FTL, y sin markdown huérfano." + } else { + let hard = ($findings | where severity == "Hard") + print $"($hard | length) Hard · (($findings | length) - ($hard | length)) aviso\(s\)" + print ($findings | select severity seam lang what | sort-by severity seam) + } + + if $check and (($findings | where severity == "Hard" | length) > 0) { exit 1 } +} diff --git a/domains/rustelo/domain.ncl b/domains/rustelo/domain.ncl index 9887758..296a200 100644 --- a/domains/rustelo/domain.ncl +++ b/domains/rustelo/domain.ncl @@ -19,7 +19,28 @@ s.Domain & { commands = [ { id = "layers", description = "List application layers declared in catalog" }, { id = "plugins", description = "Plugin registry: registered, active, interface contracts" }, - { id = "routes", description = "Route manifest: paths, handlers, auth requirements" }, + # Declared from the start and answering `not yet implemented` for its entire life — the + # project's own pathogen on its own domain surface: a capability announced on a queryable + # protocol surface, doing nothing, with nothing noticing. Now it is the WITNESS across the + # three levels a rustelo page spans, and each seam it checks is one that fails SILENTLY: + # + # route → page_id → FTL a route with no FTL keys does NOT 404. `static.j2` renders + # `texts[page_id ~ "-page-title"] | default(page_id)`, so the page + # comes back 200 with its own id as the title. Seven of them are + # live. A broken page that returns 200 is worse than a 404: nothing + # alerts, and no log records it. + # content → route `content/pages/**` is NOT a content-kind and NO Rust code reads + # it. A .md there is dead by construction — and it looks like the + # obvious place to edit. The privacy policy lives in BOTH the FTL + # (served) and the markdown (dead), and nothing checks they agree. + # A legal document, declared twice, no witness. + { id = "routes", + description = "Route manifest, and the witness across the three levels: routes (framework/build) → page_id → FTL text (instance), plus orphan markdown in the content tree", + usage = "ore rustelo routes [--check]", + examples = [ + "ore rustelo routes # report the manifest and every seam", + "ore rustelo routes --check # exit 1 on a broken seam (CI gate)", + ] }, { id = "wasm", description = "WASM build targets and feature flags" }, { id = "publish-contract", description = "Content-publisher consumption contract: the 6 answers + their oracles, declared once on the producer" }, { id = "probe", description = "Run the end-to-end publish->activate->index oracle in this RusteloApp (ADR-056 witnessed verification)" }, diff --git a/ontology/defaults/positioning.ncl b/ontology/defaults/positioning.ncl index 0997977..b693cca 100644 --- a/ontology/defaults/positioning.ncl +++ b/ontology/defaults/positioning.ncl @@ -151,6 +151,24 @@ let _mechanism_non_empty = std.contract.custom ( 'Ok value ) in +# A service-backed mechanism cannot be 'Live while its execution backend is not +# operational. The drift-audit property applied to execution: the positioning +# layer refuses to publish a channel as live before the service that runs it is +# actually configured and serving. Mechanisms without a backend (Web, Talk, …) +# are unaffected — the gate only fires when a backend is present. +let _backend_live_requires_operational = std.contract.custom ( + fun label => + fun value => + if value.status == 'Live + && std.record.has_field "backend" value + && !value.backend.operational then + 'Error { + message = "mechanism '%{value.id}': status='Live but backend not operational — %{value.backend.service} at %{value.backend.endpoint} must serve before the channel goes Live" + } + else + 'Ok value +) in + # proof: converges must carry >=1 id on each axis — the triple seam. A proof # is the convergence of all three or it is not a proof. let _proof_converges_all = std.contract.custom ( @@ -254,10 +272,11 @@ let _viability_live_requires_adr = std.contract.custom ( make_competitor = fun data => s.Competitor & data, make_difusion_mechanism = fun data => - let r1 | _mechanism_non_empty = s.DifusionMechanism & data in - let r2 | _retired_has_date = r1 in - let _ | _each_launch_check_well_formed = r2.launch_criteria in - r2, + let r1 | _mechanism_non_empty = s.DifusionMechanism & data in + let r2 | _retired_has_date = r1 in + let r3 | _backend_live_requires_operational = r2 in + let _ | _each_launch_check_well_formed = r3.launch_criteria in + r3, make_proof = fun data => let r1 | _proof_converges_all = s.Proof & data in @@ -280,6 +299,7 @@ let _viability_live_requires_adr = std.contract.custom ( Differentiator = s.Differentiator, Competitor = s.Competitor, DifusionMechanism = s.DifusionMechanism, + MechanismBackend = s.MechanismBackend, Proof = s.Proof, ViabilityPath = s.ViabilityPath, LaunchCheck = s.LaunchCheck, diff --git a/ontology/schemas/bond.ncl b/ontology/schemas/bond.ncl new file mode 100644 index 0000000..e8eccd7 --- /dev/null +++ b/ontology/schemas/bond.ncl @@ -0,0 +1,128 @@ +# bond.ncl — contract for the ontoref BOND: a typed relation between projects +# (and their domains), distinct from a `link` (the generic `ln`/Link node +# reference). Frozen semantics live in .ontoref/ontology/glossary.ncl +# (terms: bond, ontoref-node, ontoref-plane); this file types them. +# +# Session 2026-07-17 (rustelo instantiation map + domain-model brief). +# Frame is native ontoref (nodes/edges/axes/planes). DDD Context-Map is borrowed +# ONLY as the dependency-edge type enum; Hexagonal only intra-domain. +# +# ondaod: +# - formalization-vs-adoption — the type is formal; membership cardinality is +# UNCAPPED and a bond is declarable WITHOUT ontoref managing the project (the +# carrier BondSet lives in .domains-ontoref//, orthogonal to the +# manifest's scalar repo_kind). +# - enforcement-vs-emergence — `dep_kind` seeds the 6 DDD kinds already witnessed; +# the protocol supplies the mechanism, never the number. No cardinality cap. +# - ontology-vs-reflection — the TYPES live here (ontology); the `bond` verb, +# forms and FSM execution live in reflection. `linkage_transitions` is the +# canonical data the verb consults, not behaviour. +# - plane-habitability — the mutual/Partnership dependency bond IS a habitable +# plane (a 2-cycle), never a hierarchy. The `mutual` flag marks it. + +# ── Axis ───────────────────────────────────────────────────────────────────── +# Governance = the project is an ontoref-node in a domain's DAG (acyclic). +# Dependency = the project inhabits an ontoref-plane with peer nodes (cycles OK). +let bond_axis_type = [| 'Governance, 'Dependency |] in + +# ── Dependency edge kind — DDD Context-Map (applies when axis = 'Dependency) ── +# Seeded with the 6 kinds witnessed in the constellation; the enum grows by +# evidence (enforcement-vs-emergence), never by speculation. +let dep_kind_type = [| + 'CustomerSupplier, + 'Partnership, + 'OpenHostService, + 'Conformist, + 'SharedKernel, + 'AnticorruptionLayer, +|] in + +# ── Governance resolution (applies when axis = 'Governance) ─────────────────── +# How a domain (level N) resolves against its governed nodes (level N+1). +# ADR-018 (level hierarchy + mode resolution), ADR-045 (recursive level chains). +let resolution_type = [| 'Delegate, 'Override, 'Compose |] in + +# ── Linkage state — the FSM a bonded project moves through ──────────────────── +# 'None — not bonded (default). +# 'Linked — bonded on the DEPENDENCY axis (inhabits a plane; see `plane`). +# 'Node — bonded on the GOVERNANCE axis (a governed node in a domain's DAG). +# 'Managed — ontoref governs the project's development (full project management). +# The `linked(plane)` case of the brief = state 'Linked + a populated `plane` +# field (bare-tag enums carry no payload — matches core.ncl style). +let linkage_state_type = [| 'None, 'Linked, 'Node, 'Managed |] in + +let linkage_transition_type = [| 'Onboard, 'Offboard, 'Update |] in + +let linkage_transition = { + from | linkage_state_type, + via | linkage_transition_type, + to | linkage_state_type, + note | String | default = "", +} in + +# Canonical FSM the `bond init|update|drop` verb consults (data, not behaviour). +# Onboard climbs (None→Linked/Node→Managed); Update is a self-loop (retype the +# same-tier bond); Offboard descends (Managed→Node, then →None). +let default_linkage_transitions | Array linkage_transition = [ + { from = 'None, via = 'Onboard, to = 'Linked, note = "declare a dependency bond — inhabit a plane" }, + { from = 'None, via = 'Onboard, to = 'Node, note = "declare a governance bond — become a governed node" }, + { from = 'Linked, via = 'Onboard, to = 'Managed, note = "escalate to full ontoref management" }, + { from = 'Node, via = 'Onboard, to = 'Managed, note = "escalate to full ontoref management" }, + { from = 'Linked, via = 'Update, to = 'Linked, note = "retype dep_kind / mutual / capabilities" }, + { from = 'Node, via = 'Update, to = 'Node, note = "retype resolution / capabilities" }, + { from = 'Managed, via = 'Update, to = 'Managed, note = "retune the managed bond" }, + { from = 'Managed, via = 'Offboard, to = 'Node, note = "demote from management to a governance node" }, + { from = 'Node, via = 'Offboard, to = 'None, note = "drop the governance bond" }, + { from = 'Linked, via = 'Offboard, to = 'None, note = "drop the dependency bond" }, +] in + +# ── The bond record ─────────────────────────────────────────────────────────── +# `from`/`to` name the DOMAIN ROOTS of the two endpoints — a bond resolves +# against their definitions/attributes/states/capabilities, NEVER on the edge +# (glossary::bond). The edge is thin: `capabilities` are reference ids into a +# domain root's catalog, not inline definitions (ontology-vs-reflection). +# +# Field applicability by axis (not enforceable in bare Nickel — stated, and the +# `bond validate` verb checks it): +# axis = 'Governance → `resolution` meaningful; `dep_kind`/`mutual`/`plane` unused. +# axis = 'Dependency → `dep_kind`/`mutual`/`plane` meaningful; `resolution` unused. +let bond_type = { + id | String, # stable slug, e.g. "rustelo-app-to-rustelo" + from | String, # this project's domain root (the bonding endpoint) + to | String, # the target domain root + axis | bond_axis_type, + state | linkage_state_type | default = 'None, + # Dependency-axis fields + dep_kind | dep_kind_type | default = 'CustomerSupplier, + mutual | Bool | default = false, + plane | String | default = "", # which ontoref-plane (when 'Linked) + # Governance-axis field + resolution | resolution_type | default = 'Delegate, + # Reference ids into the endpoints' domain-root catalogs (never inline defs). + capabilities | Array String | default = [], + note | String | default = "", +} in + +# ── Carrier — what a bonded project declares on disk ────────────────────────── +# One file per domain folder: .domains-ontoref//bond.ncl : BondSet. +# Multi-domain membership = multiple / subfolders, each a BondSet — never +# derived from the manifest's single `repo_kind` (formalization-vs-adoption: the +# cardinality is uncapped and lives outside project management). +let bond_set_type = { + domain | String, # the domain these bonds target (matches folder name) + bonds | Array bond_type, +} in + +{ + BondAxis = bond_axis_type, + DepKind = dep_kind_type, + Resolution = resolution_type, + LinkageState = linkage_state_type, + LinkageTransition = linkage_transition, + LinkageTransitionKind = linkage_transition_type, + Bond = bond_type, + BondSet = bond_set_type, + + # Canonical FSM, exported for the `bond` verb and forms to consult. + linkage_transitions = default_linkage_transitions, +} diff --git a/ontology/schemas/positioning.ncl b/ontology/schemas/positioning.ncl index c9d6907..2c06c65 100644 --- a/ontology/schemas/positioning.ncl +++ b/ontology/schemas/positioning.ncl @@ -50,13 +50,23 @@ let audience_type = { status | audience_status_type | default = 'Hypothesis, # Validated requires a Proof (rule 2) } in +# Value-prop localization — the scalar `claim`/`problem_solved` remain the CANONICAL +# (English) source the Rust `ValueProp` struct deserializes; the OPTIONAL `*_i18n` +# siblings carry only TRANSLATIONS. `es` is required (a sibling exists to translate); +# `en` is optional and only overrides the scalar if ever needed. The site generator +# uses the sibling for a lang when that lang key is present, else the scalar — so +# English is never duplicated and the 16 unmigrated vps carry neither field. +let vp_i18n = { es | String, en | String | optional } in + # Value proposition — one file per claim. let value_prop_type = { id | String, # e.g. "vp-001-structure-that-remembers" - claim | String, # the nuclear one-liner + claim | String, # the nuclear one-liner (canonical/English) + claim_i18n | vp_i18n | optional, # translations of claim; generator prefers this per-lang audience_ids | Array String, # references audience_type.id (>=1 required, contract enforced) differentiator_ids | Array String | default = [], # QUÉ×QUIÉN seam: differentiators this claim binds (>=1 when Live) - problem_solved | String, # the problem this claim addresses + problem_solved | String, # the problem this claim addresses (canonical/English) + problem_solved_i18n | vp_i18n | optional, # translations of problem_solved; generator prefers this per-lang alternatives | Array String | default = [], # competition / status-quo options the user might consider unique_angle | String, # why no one else solves it this way evidence_adrs | Array String | default = [], # ADR ids that materially back this claim (required when status='Live) @@ -120,9 +130,31 @@ let competitor_type = { # DifusionMechanism — one file per vehicle of difusión. Difusión is the # category; a Campaign is one time-boxed coordination within it. A mechanism is # persistent (Web, DocsSite, McpServer, Repo) or an event (Content, Talk, -# Event). Admissible only with >=1 channel and >=1 thing it difunde — enforced -# by make_difusion_mechanism. -let mechanism_kind_type = [| 'Web, 'DocsSite, 'McpServer, 'Repo, 'Content, 'Talk, 'Event |] in +# Event). The service-backed kinds (Newsletter, Forms, Community, Crm) reach an +# audience through a self-hosted execution backend (a libre-wuji component) +# instead of rendering from the ontology — for those, `backend` carries the +# junction to the running service. Admissible only with >=1 channel and >=1 +# thing it difunde — enforced by make_difusion_mechanism. +let mechanism_kind_type = + [| 'Web, 'DocsSite, 'McpServer, 'Repo, 'Content, 'Talk, 'Event, + 'Newsletter, 'Forms, 'Community, 'Crm |] in + +# Execution backend for a difusión mechanism — the self-hosted service that +# actually runs the channel. Optional: presentation/content mechanisms (Web, +# DocsSite, Talk) render from the ontology and carry no backend. When present, +# `operational` is the coherence gate: a channel cannot declare status='Live +# while its backend is not configured and serving (enforced by +# make_difusion_mechanism). This is the drift-audit property applied to +# execution — the model refuses to claim a channel is live before it operates. +let mechanism_backend_type = { + service | String, # libre-wuji component name, e.g. "listmonk" + endpoint | String, # public URL of the running service + operational | Bool | default = false, # backend configured and serving — gates 'Live + admin_model | String | default = "", # how the admin / first account is obtained + secret_ref | String | optional, # SOPS basename: infra/libre-wuji/secrets/.sops.yaml + guard_ids | Array String | default = [], # cross-cutting guards before public capture (e.g. "cap") + blockers | Array String | default = [], # what stands between the current state and operational +} in let difusion_mechanism_type = { id | String, kind | mechanism_kind_type, @@ -132,6 +164,7 @@ let difusion_mechanism_type = { channels | Array String | default = [], # where it runs (>=1, contract enforced) value_prop_ids | Array String | default = [], differentiator_ids | Array String | default = [], + backend | mechanism_backend_type | optional, # self-hosted executor (service-backed kinds) launch_criteria | Array launch_check_type | default = [], status | status_type, retired_at | String | optional, @@ -231,6 +264,7 @@ let viability_path_type = { Differentiator = differentiator_type, Competitor = competitor_type, DifusionMechanism = difusion_mechanism_type, + MechanismBackend = mechanism_backend_type, Proof = proof_type, ViabilityPath = viability_path_type, } diff --git a/ontoref b/ontoref index abe4fcc..4aefa9d 100755 --- a/ontoref +++ b/ontoref @@ -1,6 +1,6 @@ #!/bin/bash # ontoref — canonical entry point. Shell alias: ontoref -# Release: 0.1.7 +# Release: 0.1.20 # Responsibilities: # 1. Verify Nushell >= 0.110.0 is installed # 2. Detect actor (developer | admin | agent | ci)