ontoref/reflection/templates/project-full-adoption-prompt.md
Jesús Pérez da083fb9ec
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
.coder/m
2026-03-29 00:19:56 +00:00

22 KiB
Raw Permalink Blame History

Ontoref Full Adoption Prompt

Purpose: Complete onboarding of {project_name} into the ontoref protocol — or bring an existing adoption up to the current version. Covers all adoption layers in dependency order: protocol infrastructure → ontology enrichment → config surface → API surface → manifest self-interrogation.

Substitutions required before use:

  • {project_name} — kebab-case project identifier
  • {project_dir} — absolute path to project root
  • {ontoref_source_dir} — absolute path to the ontoref source checkout (only needed for Cargo path dependencies in Phases 3c and 4a; not needed if ontoref crates are not used as Rust dependencies)

Run as: ontoref --actor developer from {project_dir} (requires ontoref installed globally via just install-daemon from the ontoref repo).


Bootstrap — source ontoref env vars

Before running any direct nickel export command, source the ontoref env into the current shell. This sets NICKEL_IMPORT_PATH and ONTOREF_ROOT without launching a full command:

cd {project_dir}
. $(which ontoref) --env-only
# NICKEL_IMPORT_PATH and ONTOREF_ROOT are now available in this shell session

All nickel export commands in this prompt assume these vars are set. Re-run the source line if you open a new terminal.


Phase 0 — Read the project first

Do not write anything until you have read and understood the project. This phase is not optional — subsequent phases require accurate knowledge of what the project actually does.

cd {project_dir}

# Purpose, architecture, stack
cat README.md
cat .claude/CLAUDE.md 2>/dev/null || true
cat CLAUDE.md 2>/dev/null || true

# Existing ontology state
test -f .ontology/core.ncl && \
  nickel export --import-path "$NICKEL_IMPORT_PATH" .ontology/core.ncl \
    | jq '{nodes: [.nodes[] | {id, name, level}], edge_count: (.edges | length)}'

# Manifest if present
test -f .ontology/manifest.ncl && \
  nickel export --import-path "$NICKEL_IMPORT_PATH" .ontology/manifest.ncl \
    | jq '{repo_kind, description}'

# Rust crates and their purposes
cat Cargo.toml 2>/dev/null | grep -A2 '\[workspace\]' || cat Cargo.toml 2>/dev/null | head -20
ls crates/ 2>/dev/null || true

# Config loading pattern: does the project use nickel export + serde?
grep -rl 'nickel export\|DaemonNclConfig\|ConfigLoader\|config_from_ncl' \
  crates/ src/ 2>/dev/null | head -10

# HTTP handlers: does the project expose an HTTP API?
grep -rl '#\[get\|#\[post\|#\[put\|Router::new\|axum\|actix' \
  crates/ src/ 2>/dev/null | head -10

# External services: what does it connect to?
grep -rl 'SurrealDb\|nats\|postgres\|redis\|reqwest\|http::Client' \
  crates/ src/ 2>/dev/null | head -10

Identify:

  • What the project does for each audience (developer, agent, CI, end user)
  • Whether it uses NCL schemas for configuration (Nickel-validated-overrides applies)
  • Whether it exposes an HTTP API (#[onto_api] annotation applies)
  • What external services it depends on (critical_deps candidates)
  • What the existing ontology covers vs what is missing

Phase 1 — Protocol infrastructure

Add missing v2 files. All steps are additive — nothing is overwritten.

cd {project_dir}

# Detect missing files
test -f .ontology/manifest.ncl    && echo "manifest: present"    || echo "manifest: MISSING"
test -f .ontology/connections.ncl && echo "connections: present" || echo "connections: MISSING"

# Add manifest.ncl if missing (template is installed with ontoref)
test -f .ontology/manifest.ncl || \
  sed "s/{{ project_name }}/{project_name}/g" \
    "$ONTOREF_ROOT/templates/ontology/manifest.ncl" > .ontology/manifest.ncl

# Add connections.ncl if missing
test -f .ontology/connections.ncl || \
  sed "s/{{ project_name }}/{project_name}/g" \
    "$ONTOREF_ROOT/templates/ontology/connections.ncl" > .ontology/connections.ncl

# Validate both parse
nickel export --import-path "$NICKEL_IMPORT_PATH" .ontology/manifest.ncl \
  > /dev/null && echo "manifest: ok"
nickel export --import-path "$NICKEL_IMPORT_PATH" .ontology/connections.ncl \
  > /dev/null && echo "connections: ok"

Check for deprecated check_hint fields in ADRs and migrate to typed check variants. See $ONTOREF_ROOT/reflection/templates/update-ontology-prompt.md Phase 7 for the migration mapping.


Phase 2 — Ontology enrichment (core.ncl, state.ncl)

Follow $ONTOREF_ROOT/reflection/templates/update-ontology-prompt.md Phases 26 in full.

Key rules:

  • Add nodes only for things that actually exist in code today — no aspirational nodes
  • Advance dimension states only when transition conditions are demonstrably met
  • Update blocker and catalyst to reflect current reality
  • Every edit must pass nickel export before continuing to the next node

After completing ontology enrichment, run:

nickel export --import-path "$NICKEL_IMPORT_PATH" .ontology/core.ncl \
  | jq '{nodes: (.nodes|length), edges: (.edges|length)}'

Phase 3 — Config surface

Skip this phase if the project has no structured NCL configuration system.

This phase has three parts that build on each other: declare the config surface in the manifest, apply the nickel-validated-overrides pattern to Rust services, and register struct fields via #[derive(ConfigFields)].

3a. Declare config_surface in manifest.ncl

Open .ontology/manifest.ncl. Identify:

  • Where the project's NCL config files live (config_root)
  • What the entry-point file is (entry_point, usually config.ncl)
  • What sections exist (each top-level key in the merged export)
  • Who reads each section (Rust structs, Nu scripts, CI pipelines)
  config_surface = m.make_config_surface {
    config_root    = "config/",         # adjust to project layout
    entry_point    = "config.ncl",
    kind           = 'NclMerge,         # 'NclMerge | 'SingleFile | 'TypeDialog
    contracts_path = "nickel/contracts/",  # where NCL contract files live
    overrides_dir  = "",                # defaults to config_root

    sections = [
      m.make_config_section {
        id          = "server",         # must match top-level NCL key
        file        = "config.ncl",
        contract    = "contracts.ncl -> ServerConfig",
        description = "...",
        rationale   = "...",
        mutable     = true,
        consumers   = [
          m.make_config_consumer {
            id     = "{project_name}-backend",
            kind   = 'RustStruct,
            ref    = "crates/{crate}/src/config.rs -> ServerConfig",
            fields = [],  # leave empty once #[derive(ConfigFields)] is in place
          },
        ],
      },
    ],
  },

Validate:

nickel export --import-path "$NICKEL_IMPORT_PATH" .ontology/manifest.ncl | jq .config_surface

3b. Nickel-validated-overrides pattern

Apply this if the project has Rust services that read NCL config AND accept env var or CLI argument overrides. Without this, env overrides bypass all NCL contract validation.

The core insight: JSON is valid Nickel syntax. Write env overrides as JSON to a temp .ncl file and pass it as a second argument to nickel export. Nickel merges both files and applies contracts to the merged result before any Rust struct is populated.

OLD: load_from_ncl() → deserialize → apply_env_overrides(&mut self)   ← bypasses Nickel
NEW: collect_env_overrides() → nickel export base.ncl overrides.ncl → deserialize

Step 1 — Add export_with_overrides to the config loader:

In the crate that calls nickel export (typically a platform-config or config-loader crate):

pub fn export_with_overrides(
    ncl_path: &Path,
    import_path: &str,
    overrides: &serde_json::Value,
) -> Result<String, ConfigError> {
    if overrides.is_null() || overrides == &serde_json::Value::Object(Default::default()) {
        return plain_nickel_export(ncl_path, import_path);
    }
    let tmp = tempfile::NamedTempFile::with_suffix(".ncl")?;
    serde_json::to_writer(&tmp, overrides)?;
    let output = std::process::Command::new("nickel")
        .args(["export", "--format", "json"])
        .arg(ncl_path)
        .arg(tmp.path())
        .arg("--import-path").arg(import_path)
        .output()?;
    // tmp dropped here — cleaned up automatically
    if output.status.success() {
        Ok(String::from_utf8(output.stdout)?)
    } else {
        Err(ConfigError::NickelContract(String::from_utf8_lossy(&output.stderr).into()))
    }
}

Step 2 — Replace apply_env_overrides(&mut self) with collect_env_overrides():

// REMOVE: fn apply_env_overrides(&mut self) { self.port = env::var("PORT")... }
// ADD:
pub fn collect_env_overrides() -> serde_json::Value {
    let mut overrides = serde_json::json!({});
    // JSON shape must match NCL schema nesting exactly
    if let Ok(port) = std::env::var("SERVER_PORT") {
        if let Ok(n) = port.parse::<u16>() {
            overrides["server"]["port"] = n.into();
        }
    }
    // ... one block per env var, following the NCL schema nesting
    overrides
}

Step 3 — Wire through the load path:

pub fn load() -> Result<Self, ConfigError> {
    let overrides = Self::collect_env_overrides();
    let json = export_with_overrides(&config_path, &import_path, &overrides)?;
    Ok(serde_json::from_str(&json)?)
}

Step 4 — Verify NCL schemas have real constraints, not bare type annotations:

grep -n '| Number\b\|| String\b' {project_dir}/nickel/**/*.ncl 2>/dev/null || \
  grep -rn '| Number\b\|| String\b' {project_dir}/config/ 2>/dev/null

Bare | Number passes any number. Constraints must use from_validator or from_predicate:

# Weak — any number passes
port | Number = 8080

# Strong — contract rejects values outside 1024-65535
port | std.contract.from_validator (fun port =>
  if port >= 1024 && port <= 65535 then 'Ok
  else 'Error { message = "port must be 1024-65535, got #{port}" }) = 8080

Without real constraints in NCL, the overrides pattern has no enforcement teeth.

Validation tests:

# Invalid override must produce a Nickel contract error, NOT start silently
SERVER_PORT=99999 cargo run -- 2>&1 | grep -i "nickel\|contract\|error"

# Valid override must start with the overridden value
SERVER_PORT=8090 cargo run -- 2>&1 | grep "port.*8090"

cargo clippy -- -D warnings

3c. #[derive(ConfigFields)] for compile-time field registration

Annotate every Rust struct that deserialises a config section. This gives the ontoref daemon accurate field lists without hand-maintaining fields[] in the manifest.

Add dependency (in the crate containing config structs; path is relative to that crate, assuming ontoref source is checked out as a sibling of this project):

[dependencies]
ontoref-ontology = { path = "../../{ontoref_source_dir}/crates/ontoref-ontology" }

Annotate config structs:

use ontoref_ontology::ConfigFields;

#[derive(Deserialize, ConfigFields)]
#[config_section(id = "server", ncl_file = "config/config.ncl")]
pub struct ServerConfig {
    pub host: String,
    pub port: u16,
    // #[serde(rename = "tls_enabled")] is respected — renamed name is registered
    pub tls: bool,
}

Rules:

  • id must match the section id in manifest.ncl → config_surface.sections[*].id
  • ncl_file is relative to the project root
  • Only top-level fields of the annotated struct are registered; annotate nested structs separately if their fields need to appear in the coherence report

Verify registration:

#[test]
fn config_fields_registered() {
    for entry in inventory::iter::<ontoref_ontology::ConfigFieldsEntry> {
        assert!(!entry.fields.is_empty(), "section {} has no fields", entry.section_id);
        eprintln!("section={} struct={} fields={:?}",
            entry.section_id, entry.struct_name, entry.fields);
    }
}

Coherence integration test:

#[test]
fn ncl_rust_coherence() {
    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .ancestors().find(|p| p.join(".ontology").exists())
        .expect("project root not found");

    for entry in inventory::iter::<ontoref_ontology::ConfigFieldsEntry> {
        let ncl_path = root.join(entry.ncl_file);
        let out = std::process::Command::new("nickel")
            .args(["export", "--format", "json"])
            .arg(&ncl_path).current_dir(root).output().unwrap();
        assert!(out.status.success(), "nickel export failed: {}", entry.ncl_file);

        let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
        let section = json.get(entry.section_id).or(Some(&json))
            .and_then(|v| v.as_object())
            .unwrap_or_else(|| panic!("section '{}' missing", entry.section_id));

        let ncl_keys: std::collections::BTreeSet<&str> = section.keys().map(String::as_str).collect();
        let rust_fields: std::collections::BTreeSet<&str> = entry.fields.iter().copied().collect();
        let missing: Vec<_> = rust_fields.difference(&ncl_keys).collect();
        assert!(missing.is_empty(),
            "{} declares fields not in NCL: {:?}", entry.struct_name, missing);
    }
}

Once #[derive(ConfigFields)] is in place, remove the fields = [...] lists from the corresponding config_consumer entries in manifest.ncl — the derive macro is now the source of truth.


Phase 4 — API surface

Skip this phase if the project exposes no HTTP API.

If the project has an HTTP API served by axum or actix-web, annotate each handler with #[onto_api] so the daemon can surface the full annotated catalog at GET /api/catalog.

4a. Add ontoref-derive dependency

Path is relative to the consuming crate, assuming ontoref source is a sibling project:

[dependencies]
ontoref-ontology = { path = "../../{ontoref_source_dir}/crates/ontoref-ontology" }
ontoref-derive   = { path = "../../{ontoref_source_dir}/crates/ontoref-derive" }

4b. Annotate HTTP handlers

use ontoref_derive::onto_api;

#[onto_api(
    method = "GET",
    path   = "/api/things",
    description = "List all things with optional filter.",
    auth   = "bearer",
    actors = ["developer", "agent"],
    params = [("filter", "string", false, "optional substring filter")],
    tags   = ["things", "read"]
)]
async fn list_things(/* axum extractors */) -> impl IntoResponse { /* ... */ }

Fields:

  • method — HTTP verb in caps: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
  • path — route path as registered in the router (e.g. "/projects/{slug}/things")
  • description — one sentence, agent-readable
  • auth"bearer" | "admin" | "none"
  • actors — list of actors allowed: "developer" | "agent" | "ci" | "admin"
  • params — array of (name, type, required, description) tuples
  • tags — grouping tags for catalog filtering

Register inventory collection in the crate's lib.rs or main.rs:

// In ontoref-ontology already: inventory::collect!(ApiRouteEntry);
// In your crate: this is automatic — inventory::submit! is emitted by the macro.

4c. Expose the catalog endpoint

Add the catalog route to the router (if not already provided by ontoref-daemon):

// If embedding ontoref-daemon routes:
// GET /api/catalog is already registered by ontoref-daemon's router.

// If building a standalone service with its own router, add:
use ontoref_ontology::ApiRouteEntry;

async fn api_catalog() -> axum::Json<serde_json::Value> {
    let routes: Vec<_> = inventory::iter::<ApiRouteEntry>
        .map(|r| serde_json::json!({
            "method": r.method, "path": r.path, "description": r.description,
            "auth": r.auth, "actors": r.actors, "params": r.params, "tags": r.tags,
        }))
        .collect();
    axum::Json(serde_json::json!({ "routes": routes }))
}

4d. Export the catalog for ontoref UI visibility

The ontoref daemon reads #[onto_api] entries from its own inventory at runtime. Consumer projects run as separate binaries — their entries are never linked into the ontoref-daemon process. To make the API surface visible in the ontoref UI, generate a static api-catalog.json in the project root and commit it.

Add --dump-api-catalog to the daemon binary's Cli struct (in main.rs):

/// Print all #[onto_api] registered routes as JSON and exit.
/// Pipe to api-catalog.json so the ontoref UI can display this project's
/// API surface when registered as a non-primary slug.
#[arg(long)]
dump_api_catalog: bool,

Add an early-exit handler before the server starts (same pattern as --hash-password):

if cli.dump_api_catalog {
    println!("{}", ontoref_ontology::api::dump_catalog_json());
    return;
}

Add the just recipe — in justfiles/assets.just (create if absent, import in root justfile):

# Export this daemon's API catalog to api-catalog.json.
# Run after any #[onto_api] annotation is added or changed.
# Read by the ontoref UI for non-primary project slugs.
[doc("Export #[onto_api] routes to api-catalog.json")]
export-api-catalog:
    cargo run -p {daemon_crate} --no-default-features -- --dump-api-catalog > api-catalog.json
    @echo "exported routes to api-catalog.json"

Replace {daemon_crate} with the crate name of the project's HTTP daemon binary.

Run and commit:

just export-api-catalog
git add api-catalog.json

Commit api-catalog.json alongside the #[onto_api] annotations — they change together.

4e. Verify

cargo check --all-targets
just export-api-catalog
# Confirm routes were captured:
cat api-catalog.json | jq '[.[] | {method, path, tags}]'

Phase 5 — Manifest self-interrogation

Populate capabilities[], requirements[], and critical_deps[] in .ontology/manifest.ncl.

Follow $ONTOREF_ROOT/reflection/templates/manifest-self-interrogation-prompt.md in full.

Quick reference for the three types:

  capabilities = [
    m.make_capability {
      id        = "kebab-id",
      name      = "Name",
      summary   = "One line: what does this capability do?",
      rationale = "Why it exists. What was rejected.",
      how       = "Key patterns, entry points, data flows.",
      artifacts = ["crates/foo/", "GET /api/foo"],
      adrs      = ["adr-001"],          # IDs that formalize decisions here
      nodes     = ["practice-node-id"], # IDs from .ontology/core.ncl
    },
  ],

  requirements = [
    m.make_requirement {
      id        = "id",
      name      = "Name",
      env       = 'Both,       # 'Production | 'Development | 'Both
      kind      = 'Tool,       # 'Tool | 'Service | 'EnvVar | 'Infrastructure
      version   = "",
      required  = true,
      impact    = "What breaks if absent.",
      provision = "How to install/set/provision.",
    },
  ],

  critical_deps = [
    m.make_critical_dep {
      id             = "id",
      name           = "crate-or-service",
      ref            = "crates.io: foo",
      used_for       = "Which capabilities depend on this.",
      failure_impact = "What breaks if this dep disappears or breaks its contract.",
      mitigation     = "Feature flags, fallback builds, alternatives.",
    },
  ],

Phase 6 — Final validation

cd {project_dir}

# All .ontology/ files
for f in .ontology/core.ncl .ontology/state.ncl .ontology/gate.ncl \
         .ontology/manifest.ncl .ontology/connections.ncl; do
  test -f "$f" && \
    nickel export --import-path "$NICKEL_IMPORT_PATH" "$f" > /dev/null \
    && echo "ok: $f" || echo "FAIL: $f"
done

# All ADRs
for f in adrs/adr-*.ncl; do
  nickel export --import-path "$NICKEL_IMPORT_PATH" "$f" > /dev/null \
    && echo "ok: $f" || echo "FAIL: $f"
done

# Rust: build, lint, tests
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test config_fields_registered -- --nocapture 2>/dev/null || true
cargo test ncl_rust_coherence        -- --nocapture 2>/dev/null || true

# Describe output
ontoref --actor developer describe project
ontoref --actor developer describe requirements
ONTOREF_ACTOR=agent ontoref describe capabilities | from json | get capabilities | length

Checklist

Protocol layer

  • .ontology/manifest.ncl present and exports cleanly
  • .ontology/connections.ncl present and exports cleanly
  • core.ncl nodes reflect current implementation (no aspirational nodes)
  • state.ncl dimension states match current reality
  • All check_hint fields migrated to typed check variants

Config surface

  • config_surface declared in manifest.ncl (if project uses NCL config)
  • All sections have id, file, consumers with accurate kinds
  • Nickel-validated-overrides: collect_env_overrides() implemented (if applicable)
  • Nickel-validated-overrides: apply_env_overrides(&mut self) removed
  • NCL schemas have real constraints (from_validator, not bare | Number)
  • #[derive(ConfigFields)] on all config structs that read NCL sections
  • cargo test config_fields_registered passes
  • cargo test ncl_rust_coherence passes
  • fields = [...] removed from manifest consumers once derive is in place

API surface

  • #[onto_api] on all HTTP handlers (if project has an HTTP API)
  • GET /api/catalog returns non-empty routes list
  • All routes have accurate auth, actors, tags

Manifest self-interrogation

  • description field populated (non-empty)
  • At least 1 capability entry with non-empty summary
  • capabilities[].nodes[] verified against core.ncl node IDs
  • At least 1 requirement per relevant environment
  • All critical_deps have non-empty failure_impact

Delivery

  • describe project returns complete picture
  • describe requirements renders without errors
  • No orphaned describe diff changes (everything committed or staged intentionally)
  • Do NOT commit — developer reviews the diff first