feat: config surface, NCL contracts, override-layer mutation, on+re update
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

Config surface — per-project config introspection, coherence verification, and
  audited mutation without destroying NCL structure (ADR-008):

  - crates/ontoref-daemon/src/config.rs — typed DaemonNclConfig (parse-at-boundary
    pattern); all section structs derive ConfigFields + config_section(id, ncl_file)
    emitting inventory::submit!(ConfigFieldsEntry{...}) at link time
  - crates/ontoref-derive/src/lib.rs — #[derive(ConfigFields)] proc-macro; serde
    rename support; serde_rename_of() helper extracted to fix excessive_nesting
  - crates/ontoref-daemon/src/main.rs — 3-tuple bootstrap block (nickel_import_path,
    loaded_ncl_config: Option<DaemonNclConfig>, stdin_raw); apply_ui_config takes
    &UiConfig; NATS call site typed; resolve_asset_dir cfg(feature = "ui")
  - crates/ontoref-daemon/src/api.rs — config GET/PUT endpoints, quickref, coherence,
    cross-project comparison; index_section_fields() extracted (excessive_nesting)
  - crates/ontoref-daemon/src/config_coherence.rs — multi-consumer coherence;
    merge_meta_into_section() extracted; and() replaces unnecessary and_then

  NCL contracts for ontoref's own config:
  - .ontoref/contracts.ncl — LogConfig (LogLevel, LogRotation, PositiveInt) and
    DaemonConfig (Port, optional overrides); std.contract.from_validator throughout
  - .ontoref/config.ncl — log | C.LogConfig applied
  - .ontology/manifest.ncl — contracts_path, log/daemon contract refs, daemon section
    with DaemonRuntimeConfig consumer and 7 declared fields

  Protocol:
  - adrs/adr-008-ncl-first-config-validation-and-override-layer.ncl — NCL contracts
    as single validation gate; Rust structs are contract-trusted; override-layer
    mutation writes {section}.overrides.ncl + _overrides_meta, never touches source

  on+re update:
  - .ontology/core.ncl — config-surface node (28 practices); adr-lifecycle extended
    to adr-007 + adr-008; 6 new edges (ManifestsIn daemon, DependsOn ontology-crate,
    Complements api-catalog-surface/dag-formalized/self-describing/adopt-ontoref)
  - .ontology/state.ncl — protocol-maturity blocker and self-description-coverage
    catalyst updated for session 2026-03-26
  - README.md / CHANGELOG.md updated
This commit is contained in:
Jesús Pérez 2026-03-26 20:20:22 +00:00
parent 085607130a
commit 401294de5d
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
45 changed files with 3739 additions and 436 deletions

View File

@ -79,9 +79,11 @@ let d = import "../ontology/defaults/core.ncl" in
"adrs/adr-004-ncl-pipe-bootstrap-pattern.ncl",
"adrs/adr-005-unified-auth-session-model.ncl",
"adrs/adr-006-nushell-0111-string-interpolation-compat.ncl",
"adrs/adr-007-api-surface-discoverability-onto-api-proc-macro.ncl",
"adrs/adr-008-ncl-first-config-validation-and-override-layer.ncl",
"CHANGELOG.md",
],
adrs = ["adr-001", "adr-002", "adr-003", "adr-004", "adr-005", "adr-006"],
adrs = ["adr-001", "adr-002", "adr-003", "adr-004", "adr-005", "adr-006", "adr-007", "adr-008"],
},
d.make_node {
@ -367,6 +369,26 @@ let d = import "../ontology/defaults/core.ncl" in
],
},
d.make_node {
id = "config-surface",
name = "Config Surface",
pole = 'Yang,
level = 'Practice,
description = "Per-project config introspection, coherence verification, and documented mutation. Rust structs annotated with #[derive(ConfigFields)] + #[config_section(id, ncl_file)] emit inventory::submit!(ConfigFieldsEntry{...}) at link time — the same inventory pattern as API catalog. The daemon queries inventory::iter::<ConfigFieldsEntry>() at startup to build a zero-maintenance registry of which Rust fields each struct reads from each NCL section. Multi-consumer coherence compares this registry against the NCL export, Nu script accessors, and CI pipeline fields declared in manifest.ncl's config_surface — any NCL field claimed by no consumer is flagged unclaimed. API endpoints: GET /projects/{slug}/config (full export), /config/{section} (single section), /config/schema (sections with contracts and consumers), /config/coherence (multi-consumer diff), /config/quickref (generated documentation with rationales, override history, coherence status). PUT /projects/{slug}/config/{section} mutates via an override layer: writes {section}.overrides.ncl with audit metadata (actor, reason, timestamp, previous value), appends a single import to the entry point (idempotent), validates with nickel export, reverts on contract violation. NCL contracts (std.contract.from_validator) enforce field constraints (enums, positive numbers, port ranges) before any Rust struct is populated — Nickel is the single validation layer. Ontoref describes its own config via .ontoref/contracts.ncl applying LogConfig and DaemonConfig contracts.",
invariant = false,
artifact_paths = [
"crates/ontoref-daemon/src/config.rs",
"crates/ontoref-daemon/src/config_coherence.rs",
"crates/ontoref-daemon/src/api.rs",
"crates/ontoref-derive/src/lib.rs",
"crates/ontoref-ontology/src/lib.rs",
"ontology/schemas/manifest.ncl",
".ontoref/contracts.ncl",
".ontoref/config.ncl",
],
adrs = ["adr-007", "adr-008"],
},
],
edges = [
@ -452,5 +474,18 @@ let d = import "../ontology/defaults/core.ncl" in
note = "--gen-keys bootstraps the first keys into project.ncl during setup." },
{ from = "project-onboarding", to = "daemon-config-management", kind = 'DependsOn, weight = 'Medium },
# Config Surface edges
{ from = "config-surface", to = "ontoref-daemon", kind = 'ManifestsIn, weight = 'High },
{ from = "config-surface", to = "ontoref-ontology-crate", kind = 'DependsOn, weight = 'High,
note = "ConfigFieldsEntry struct and inventory::collect!(ConfigFieldsEntry) live in ontoref-ontology — the zero-dep adoption surface." },
{ from = "config-surface", to = "api-catalog-surface", kind = 'Complements, weight = 'High,
note = "#[derive(ConfigFields)] extends the same inventory::submit! pattern as #[onto_api]. Both emit link-time registration entries collected by the daemon at startup." },
{ from = "config-surface", to = "dag-formalized", kind = 'ManifestsIn, weight = 'High,
note = "Config sections, consumers, and coherence reports are typed NCL/Rust records — the config tree is a queryable subgraph." },
{ from = "config-surface", to = "self-describing", kind = 'Complements, weight = 'High,
note = "Ontoref applies its own LogConfig and DaemonConfig contracts in .ontoref/contracts.ncl — the config surface is self-demonstrated, not just specified." },
{ from = "config-surface", to = "adopt-ontoref-tooling", kind = 'Complements, weight = 'Medium,
note = "Consumer projects adopting ontoref can annotate their config structs with #[derive(ConfigFields)] to participate in the coherence registry." },
],
}

View File

@ -75,6 +75,144 @@ m.make_manifest {
},
],
config_surface = m.make_config_surface {
config_root = ".ontoref",
entry_point = "config.ncl",
kind = 'SingleFile,
contracts_path = ".ontoref",
sections = [
m.make_config_section {
id = "nickel_import_paths",
file = "config.ncl",
description = "Ordered list of directories added to NICKEL_IMPORT_PATH when invoking nickel.",
rationale = "Ontoref resolves ontology schemas, ADRs, and reflection schemas through this path list. Order matters: earlier entries shadow later ones.",
mutable = true,
consumers = [
m.make_config_consumer {
id = "env",
kind = 'NuScript,
ref = "reflection/modules/env.nu",
fields = ["nickel_import_paths"],
},
m.make_config_consumer {
id = "daemon-main",
kind = 'RustStruct,
ref = "crates/ontoref-daemon/src/main.rs",
fields = ["nickel_import_paths"],
},
],
},
m.make_config_section {
id = "ui",
file = "config.ncl",
description = "Daemon HTTP/UI settings: template directory, static assets, TLS certs, logo override.",
rationale = "Allows dev-mode templates to be served from the source tree instead of the installed path, and TLS to be toggled without recompiling.",
mutable = true,
consumers = [
m.make_config_consumer {
id = "daemon-main",
kind = 'RustStruct,
ref = "crates/ontoref-daemon/src/main.rs",
fields = ["templates_dir", "public_dir", "tls_cert", "tls_key", "logo"],
},
],
},
m.make_config_section {
id = "log",
file = "config.ncl",
contract = "contracts.ncl → LogConfig",
description = "Daemon structured logging: level, rotation policy, archive and retention.",
rationale = "Daily rotation with 7-file retention keeps log footprint bounded; separate archive path allows cold storage without disrupting active logs.",
mutable = true,
consumers = [
m.make_config_consumer {
id = "daemon-main",
kind = 'RustStruct,
ref = "crates/ontoref-daemon/src/main.rs",
fields = ["level", "path", "rotation", "compress", "archive", "max_files"],
},
],
},
m.make_config_section {
id = "mode_run",
file = "config.ncl",
description = "ACL rules for which actors may execute which reflection modes.",
rationale = "Agent and CI actors need unrestricted mode access; human actors are gated per mode to prevent accidental destructive operations.",
mutable = true,
consumers = [
m.make_config_consumer {
id = "daemon-main",
kind = 'RustStruct,
ref = "crates/ontoref-daemon/src/main.rs",
fields = ["rules"],
},
],
},
m.make_config_section {
id = "nats_events",
file = "config.ncl",
description = "NATS event bus integration: enabled flag, server URL, emit/subscribe topic lists, handlers directory.",
rationale = "Disabled by default to keep ontoref zero-dependency for projects without a NATS deployment. Feature-gated in the daemon crate.",
mutable = true,
consumers = [
m.make_config_consumer {
id = "daemon-main",
kind = 'RustStruct,
ref = "crates/ontoref-daemon/src/main.rs",
fields = ["enabled", "url", "emit", "subscribe", "handlers_dir"],
},
],
},
m.make_config_section {
id = "actor_init",
file = "config.ncl",
description = "Per-actor bootstrap: which reflection mode to auto-run on first invocation.",
rationale = "Agents always auto-run 'describe capabilities' so they orient themselves before acting; developers and CI start clean.",
mutable = true,
consumers = [
m.make_config_consumer {
id = "env",
kind = 'NuScript,
ref = "reflection/modules/env.nu",
fields = ["actor", "mode", "auto_run"],
},
],
},
m.make_config_section {
id = "quick_actions",
file = "config.ncl",
description = "Shortcut actions surfaced in the daemon UI dashboard: id, label, icon, category, mode, allowed actors.",
rationale = "Frequently used modes (generate-mdbook, sync-ontology, coder-workflow) promoted to one-click access without navigating the modes list.",
mutable = true,
consumers = [
m.make_config_consumer {
id = "daemon-ui",
kind = 'RustStruct,
ref = "crates/ontoref-daemon/src/ui/handlers.rs",
fields = ["id", "label", "icon", "category", "mode", "actors"],
},
],
},
m.make_config_section {
id = "daemon",
file = "config.ncl",
contract = "contracts.ncl → DaemonConfig",
description = "Runtime overrides for daemon CLI defaults: port, timeouts, sweep intervals, notification limits.",
rationale = "All fields are optional — absent fields use the daemon's built-in CLI defaults. Set only when the defaults need project-specific tuning without rebuilding the binary.",
mutable = true,
consumers = [
m.make_config_consumer {
id = "daemon-config",
kind = 'RustStruct,
ref = "crates/ontoref-daemon/src/config.rs → DaemonRuntimeConfig",
fields = ["port", "idle_timeout", "invalidation_interval", "actor_sweep_interval", "actor_stale_timeout", "max_notifications", "notification_ack_required"],
},
],
},
],
},
layers = [
m.make_layer {
id = "protocol",

View File

@ -25,7 +25,7 @@ let d = import "../ontology/defaults/state.ncl" in
to = "protocol-stable",
condition = "ADR-001 accepted, ontoref.dev published, at least two external projects consuming the protocol.",
catalyst = "First external adoption.",
blocker = "ontoref.dev not yet published; no external consumers yet. Auth model complete. Install pipeline complete. Personal/career schema layer present; content modes operational. Nu 0.111 compat fixed (ADR-006). Protocol v2 complete: manifest.ncl + connections.ncl templates, update_ontoref mode, API catalog via #[onto_api], describe diff, describe api, per-file versioning. Syntaxis syntaxis-ontology crate has pending ES→EN migration errors.",
blocker = "ontoref.dev not yet published; no external consumers yet. Auth model complete. Install pipeline complete. Personal/career schema layer present; content modes operational. Nu 0.111 compat fixed (ADR-006). Protocol v2 complete: manifest.ncl + connections.ncl templates, update_ontoref mode, API catalog via #[onto_api], describe diff, describe api, per-file versioning. Config surface complete (ADR-008): typed DaemonNclConfig, #[derive(ConfigFields)] inventory coherence registry, NCL contracts (LogConfig/DaemonConfig in .ontoref/contracts.ncl), override-layer mutation API, multi-consumer manifest schema. Syntaxis syntaxis-ontology crate has pending ES→EN migration errors.",
horizon = 'Months,
},
],
@ -52,7 +52,7 @@ let d = import "../ontology/defaults/state.ncl" in
from = "modes-and-web-present",
to = "fully-self-described",
condition = "At least 3 ADRs accepted, reflection/backlog.ncl present, describe project returns complete picture.",
catalyst = "ADR-001ADR-006 authored (6 ADRs present). Auth model, project onboarding, and session management nodes added in 2026-03-13. Personal/career/project-card schemas, 5 content modes, search bookmarks, and ADR-006 (Nu 0.111 compat) added in session 2026-03-15. Session 2026-03-23: api-catalog-surface node added (#[onto_api] proc-macro + inventory catalog), describe-query-layer updated (diff + api subcommands), adopt-ontoref-tooling updated (update_ontoref mode + manifest/connections templates + enrichment prompt), ontoref-daemon updated (11 pages, 29 MCP tools, per-file versioning, API catalog endpoint).",
catalyst = "ADR-001ADR-006 authored (6 ADRs present). Auth model, project onboarding, and session management nodes added in 2026-03-13. Personal/career/project-card schemas, 5 content modes, search bookmarks, and ADR-006 (Nu 0.111 compat) added in session 2026-03-15. Session 2026-03-23: api-catalog-surface node added (#[onto_api] proc-macro + inventory catalog), describe-query-layer updated (diff + api subcommands), adopt-ontoref-tooling updated (update_ontoref mode + manifest/connections templates + enrichment prompt), ontoref-daemon updated (11 pages, 29 MCP tools, per-file versioning, API catalog endpoint). Session 2026-03-26: config-surface node added — typed DaemonNclConfig (parse-at-boundary pattern), #[derive(ConfigFields)] coherence registry, override-layer mutation API (PUT /config/{section}), NCL contracts (.ontoref/contracts.ncl: LogConfig + DaemonConfig), manifest config_surface with multi-consumer sections. ADR-007 (inventory/onto_api) extended to ConfigFields; ADR-008 (NCL-first config validation + override-layer mutation).",
blocker = "none",
horizon = 'Weeks,
},

View File

@ -1,3 +1,5 @@
let C = import "contracts.ncl" in
{
nickel_import_paths = [".", ".ontology", "ontology/schemas", "adrs", "reflection/requirements", "reflection/schemas"],
@ -9,7 +11,7 @@
logo = "ontoref-logo.svg",
},
log = {
log | C.LogConfig = {
level = "info",
path = "logs",
rotation = "daily",

62
.ontoref/contracts.ncl Normal file
View File

@ -0,0 +1,62 @@
# Contracts for .ontoref/config.ncl sections.
#
# Applied in config.ncl with `section | C.SectionContract = { ... }`.
# Consumed by the daemon coherence / quickref tooling via the
# config_surface.sections[].contract field in .ontology/manifest.ncl.
let contract = std.contract in
# ── Primitive contracts ──────────────────────────────────────────────────────
let LogLevel = contract.from_validator (fun value =>
if std.array.elem value ["error", "warn", "info", "debug", "trace"] then
'Ok
else
'Error { message = "log.level must be one of: error, warn, info, debug, trace" }
) in
let LogRotation = contract.from_validator (fun value =>
if std.array.elem value ["daily", "hourly", "never"] then
'Ok
else
'Error { message = "log.rotation must be one of: daily, hourly, never" }
) in
let PositiveInt = contract.from_validator (fun value =>
if std.is_number value && value > 0 then
'Ok
else
'Error { message = "value must be a positive integer (> 0)" }
) in
let Port = contract.from_validator (fun value =>
if std.is_number value && value >= 1 && value <= 65535 then
'Ok
else
'Error { message = "port must be a number between 1 and 65535" }
) in
# ── Section contracts ────────────────────────────────────────────────────────
{
LogConfig = {
level | LogLevel | default = "info",
path | String | default = "logs",
rotation | LogRotation | default = "daily",
compress | Bool | default = false,
archive | String | default = "logs-archive",
max_files | PositiveInt | default = 7,
},
# All daemon fields are optional — they override CLI defaults only when set.
# Absent fields fall back to the daemon's built-in defaults (see Cli struct).
DaemonConfig = {
port | Port | optional,
idle_timeout | PositiveInt | optional,
invalidation_interval | PositiveInt | optional,
actor_sweep_interval | PositiveInt | optional,
actor_stale_timeout | PositiveInt | optional,
max_notifications | PositiveInt | optional,
notification_ack_required | Array String | default = [],
},
}

View File

@ -26,7 +26,7 @@ repos:
- id: rust-test
name: Rust tests
entry: bash -c 'cargo test --workspace'
entry: bash -c 'cargo test --all-features --workspace'
language: system
types: [rust]
pass_filenames: false

View File

@ -7,6 +7,106 @@ ADRs referenced below live in `adrs/` as typed Nickel records.
## [Unreleased]
### Config Surface — typed config, NCL contracts, override-layer mutation
Per-project config introspection, coherence verification, and audited mutation. NCL contracts are the single
validation gate; config mutation never modifies source NCL files.
#### `crates/ontoref-daemon/src/config.rs` — typed `DaemonNclConfig` (parse-at-boundary)
- `DaemonNclConfig` — top-level deserialize target for `nickel export .ontoref/config.ncl | daemon --config-stdin`;
fields: `nickel_import_paths`, `ui: UiConfig`, `log: LogConfig`, `mode_run: ModeRunConfig`,
`nats_events: NatsEventsConfig`, `actor_init: Vec<ActorInit>`, `quick_actions: Vec<QuickAction>`,
`daemon: DaemonRuntimeConfig`. `#[cfg(feature = "db")] db: DbConfig`. All `#[serde(default)]`.
- Each section struct derives `#[derive(Deserialize, ConfigFields)]` + `#[config_section(id, ncl_file)]`
— emits `inventory::submit!(ConfigFieldsEntry{...})` at link time.
- `DaemonRuntimeConfig` — optional port, timeouts, sweep intervals, `notification_ack_required: Vec<String>`.
#### `crates/ontoref-daemon/src/main.rs` — 3-tuple bootstrap block
- Bootstrap block changed to `(nickel_import_path, loaded_ncl_config, stdin_raw)``loaded_ncl_config: Option<DaemonNclConfig>`
replaces raw `Option<serde_json::Value>`. `stdin_raw: Option<serde_json::Value>` retained only
for service-mode `projects` extraction.
- `apply_stdin_config` now deserializes JSON to `DaemonNclConfig` before applying CLI overrides;
`apply_ui_config` signature changed from `&serde_json::Value` to `&UiConfig`.
- `load_config_overrides` returns `(Option<String>, Option<DaemonNclConfig>)` — all `.get("daemon").and_then(...)` chains
replaced with typed field access (`ncl.daemon.port`, etc.).
- NATS call site updated to `loaded_ncl_config.as_ref().map(|c| &c.nats_events)`.
- `resolve_asset_dir` gated with `#[cfg(feature = "ui")]`; `#[allow(unused_variables)]` on bootstrap tuple for `--no-default-features`.
#### `crates/ontoref-derive/src/lib.rs``#[derive(ConfigFields)]` macro
- New `proc_macro_derive` `ConfigFields` with helper attribute `config_section(id, ncl_file)`.
Extracts serde-renamed field names; emits `inventory::submit!(ConfigFieldsEntry{section_id, ncl_file, struct_name, fields})`.
- Extracted `serde_rename_of(field)` helper to fix `clippy::excessive_nesting` (depth was 6).
Field names collected via `.iter().map(|f| serde_rename_of(f).unwrap_or_else(|| f.ident...)).filter(|s| !s.is_empty())`.
#### `crates/ontoref-daemon/src/config_coherence.rs` — clippy fixes
- `and_then(|_| full_export.as_ref())``.and(full_export.as_ref())` (unnecessary lazy evaluation).
- Extracted `merge_meta_into_section` helper to reduce nesting depth for `_meta_*` record merging.
#### `crates/ontoref-daemon/src/api.rs``index_section_fields` helper
- Extracted `index_section_fields` to fix `clippy::excessive_nesting` at the cross-project field indexing loop.
Skips `_meta_*` and `_overrides_meta` keys; indexes `(section_id, field) → Vec<(slug, value)>`.
#### `.ontoref/contracts.ncl` — new file
NCL contracts for ontoref's own config sections using `std.contract.from_validator` (not the deprecated `fun label value =>` pattern):
- `LogLevel` — enum validator: `error | warn | info | debug | trace`
- `LogRotation` — enum validator: `daily | hourly | never`
- `PositiveInt``value > 0 && is_number`
- `Port``value >= 1 && value <= 65535`
- `LogConfig` — applies per-field contracts + defaults (`level = "info"`, `rotation = "daily"`, `max_files = 7`)
- `DaemonConfig` — all fields optional (override-only); port, timeouts, intervals, `notification_ack_required`
#### `.ontoref/config.ncl` — contracts applied
- `let C = import "contracts.ncl"` added at top.
- `log | C.LogConfig = { ... }` — contract enforced before JSON reaches Rust.
#### `.ontology/manifest.ncl` — config surface enriched
- `contracts_path = ".ontoref"` added to `config_surface`.
- `log` section: `contract = "contracts.ncl → LogConfig"` added.
- New `daemon` section: `contract = "contracts.ncl → DaemonConfig"`, consumer `daemon-config` pointing to
`crates/ontoref-daemon/src/config.rs → DaemonRuntimeConfig` with 7 declared fields.
### Protocol
- ADR-007 extended: `#[derive(ConfigFields)]` is a second application of the `inventory::submit!` / `inventory::collect!`
linker registration pattern first established by `#[onto_api]`. Both are now referenced from the `config-surface` node.
- ADR-008 accepted: NCL-first config validation and override-layer mutation. NCL contracts are the single validation gate;
Rust structs are contract-trusted with `#[serde(default)]`. Config mutation writes `{section}.overrides.ncl` with
`_overrides_meta` audit record; original NCL source files are never modified. nickel export validates the merged
result before commit; contract violations revert the override file.
([adr-008](adrs/adr-008-ncl-first-config-validation-and-override-layer.ncl))
### Self-Description — on+re Update
`.ontology/core.ncl` — new Practice node, updated nodes, 6 new edges:
| Change | Detail |
| --- | --- |
| New node `config-surface` | Yang — typed DaemonNclConfig, ConfigFields inventory registry, override-layer mutation API, NCL contracts, multi-consumer manifest schema; `adrs = ["adr-007", "adr-008"]` |
| Updated `adr-lifecycle` | ADR-007 + ADR-008 added to `artifact_paths` and `adrs` list (now 8 ADRs) |
New edges: `config-surface → ontoref-daemon` (ManifestsIn/High),
`config-surface → ontoref-ontology-crate` (DependsOn/High — ConfigFieldsEntry lives in zero-dep crate),
`config-surface → api-catalog-surface` (Complements/High — shared inventory pattern),
`config-surface → dag-formalized` (ManifestsIn/High),
`config-surface → self-describing` (Complements/High — ontoref validates its own config with its own contracts),
`config-surface → adopt-ontoref-tooling` (Complements/Medium).
`.ontology/state.ncl``protocol-maturity` blocker updated to record config surface completion.
`self-description-coverage` catalyst updated with session 2026-03-26 additions.
Previous: 4 axioms, 2 tensions, 27 practices. Current: 4 axioms, 2 tensions, 28 practices.
---
### API Catalog Surface — `#[onto_api]` proc-macro
Annotated HTTP surface discoverable at compile time via `inventory`.

1
Cargo.lock generated
View File

@ -2891,6 +2891,7 @@ dependencies = [
"libc",
"notify",
"ontoref-derive",
"ontoref-ontology",
"platform-nats",
"reqwest",
"rmcp",

View File

@ -37,7 +37,7 @@ crates/ Rust implementation — typed struct loaders and mode executo
| `ontoref-ontology` | `.ontology/` NCL → typed Rust structs: Node, Edge, Dimension, Gate, Membrane. `Node` carries `artifact_paths` and `adrs` (`Vec<String>`, both `serde(default)`). Graph traversal, invariant queries. Zero deps. |
| `ontoref-reflection` | NCL DAG contract executor: ADR lifecycle, step dep resolution, config seal. `stratum-graph` + `stratum-state` required. |
| `ontoref-daemon` | HTTP UI (11 pages), actor registry, notification barrier, MCP (29 tools), search engine, search bookmarks, SurrealDB, NCL export cache, per-file ontology versioning, annotated API catalog. |
| `ontoref-derive` | Proc-macro crate. `#[onto_api(...)]` annotates HTTP handlers; `inventory::submit!` emits route entries at link time. `GET /api/catalog` aggregates them via `inventory::collect!`. |
| `ontoref-derive` | Proc-macro crate. `#[onto_api(...)]` annotates HTTP handlers; `#[derive(ConfigFields)]` + `#[config_section(id, ncl_file)]` registers config struct fields — both emit `inventory::submit!` at link time. `GET /api/catalog` and `GET /config/coherence` aggregate via `inventory::collect!`. |
`ontoref-daemon` caches `nickel export` results (keyed by path + mtime), reducing full sync
scans from ~2m42s to <30s. The daemon is always optional every module falls back to direct
@ -110,6 +110,27 @@ MISSING/STALE/DRIFT/BROKEN items are found. Never applies changes — `apply` is
`.ontoref/config.ncl`. Accessible from HTTP (`/actions`), CLI (`ontoref`), and MCP
(`ontoref_action_list/add`).
**Config Surface** — per-project config introspection, coherence verification, and documented
mutation. Rust structs annotated with `#[derive(ConfigFields)]` + `#[config_section(id, ncl_file)]`
register their field names at link time via `inventory::submit!(ConfigFieldsEntry{...})`. The daemon
queries `inventory::iter::<ConfigFieldsEntry>()` at startup to build a zero-maintenance registry of
which Rust fields each struct reads from each NCL section. Multi-consumer coherence
(`GET /projects/{slug}/config/coherence`) compares the inventory registry against NCL export keys,
Nu script accessor patterns, and CI fields declared in `manifest.ncl` — any NCL field claimed by no
consumer is flagged unclaimed. `GET /projects/{slug}/config/quickref` generates living documentation
(rationales, override history, coherence status) on demand.
Config mutation never modifies source NCL files. `PUT /projects/{slug}/config/{section}` writes a
`{section}.overrides.ncl` file with only the changed fields plus a `_overrides_meta` audit record
(actor, reason, timestamp, previous value), then appends a single idempotent import line to the
entry-point NCL using the `&` merge operator. `nickel export` validates the merged result against the
section's declared contract before committing; contract violations revert the override file and return
the nickel error verbatim. NCL contracts (`std.contract.from_validator`) are the single validation
gate — Rust structs are contract-trusted readers with `#[serde(default)]`.
Ontoref demonstrates the pattern on itself: `.ontoref/contracts.ncl` applies `LogConfig` and
`DaemonConfig` contracts to `.ontoref/config.ncl`. ([ADR-008](adrs/adr-008-ncl-first-config-validation-and-override-layer.ncl))
## Install
```sh

View File

@ -74,11 +74,7 @@ d.make_adr {
claim = "The bootstrap pipeline must not write an intermediate config file to disk at any stage",
scope = "scripts/ontoref-daemon-start, reflection/nulib/bootstrap.nu",
severity = 'Hard,
check = 'Grep {
pattern = "tee |tempfile|mktemp",
paths = ["scripts/ontoref-daemon-start"],
must_be_empty = true,
},
check = { tag = 'Grep, pattern = "tee |tempfile|mktemp", paths = ["scripts/ontoref-daemon-start"], must_be_empty = true },
rationale = "An intermediate file defeats the purpose of the pipeline. If a file is needed for debugging, use --dry-run which prints to stdout only.",
},
{
@ -86,7 +82,7 @@ d.make_adr {
claim = "The bash wrapper must depend only on bash, nickel, and the target binary — no Nu, no jq unless SOPS/Vault stage is active",
scope = "scripts/ontoref-daemon-start",
severity = 'Hard,
check = 'FileExists { path = "scripts/ontoref-daemon-start", present = true },
check = { tag = 'FileExists, path = "scripts/ontoref-daemon-start", present = true },
rationale = "System service managers may not have Nu on PATH. The wrapper must be portable across launchctl, systemd, Docker entrypoints.",
},
{
@ -94,11 +90,7 @@ d.make_adr {
claim = "The target process must redirect stdin to /dev/null after reading the config JSON",
scope = "crates/ontoref-daemon/src/main.rs",
severity = 'Hard,
check = 'Grep {
pattern = "/dev/null|stdin.*close|drop.*stdin",
paths = ["crates/ontoref-daemon/src/main.rs"],
must_be_empty = false,
},
check = { tag = 'Grep, pattern = "/dev/null|stdin.*close|drop.*stdin", paths = ["crates/ontoref-daemon/src/main.rs"], must_be_empty = false },
rationale = "stdin left open blocks terminal interaction and causes confusion in interactive sessions. The daemon is a server — it must not hold stdin.",
},
{
@ -106,10 +98,7 @@ d.make_adr {
claim = "NCL config files used with ncl-bootstrap must not contain plaintext secret values — only SecretRef placeholders or empty fields",
scope = ".ontoref/config.ncl, APP_SUPPORT/ontoref/config.ncl",
severity = 'Hard,
check = 'NuCmd {
cmd = "nickel export .ontoref/config.ncl | from json | transpose key value | where { |row| $row.key =~ 'password|secret|key|token|hash' and ($row.value | describe) == 'string' and ($row.value | str length) > 0 } | length | into string",
expect_exit = 0,
},
check = { tag = 'NuCmd, cmd = "nickel export .ontoref/config.ncl | from json | transpose key value | where { |row| $row.key =~ 'password|secret|key|token|hash' and ($row.value | describe) == 'string' and ($row.value | str length) > 0 } | length | into string", expect_exit = 0 },
rationale = "If secrets are in the NCL file, they are readable as plaintext by anyone with filesystem access. Secrets enter the pipeline only at the SOPS/Vault stage.",
},
],

View File

@ -81,11 +81,7 @@ d.make_adr {
claim = "GET /sessions responses must never include the bearer token, only the public session id",
scope = "crates/ontoref-daemon/src/session.rs, crates/ontoref-daemon/src/api.rs",
severity = 'Hard,
check = 'Grep {
pattern = "pub token",
paths = ["crates/ontoref-daemon/src/session.rs"],
must_be_empty = true,
},
check = { tag = 'Grep, pattern = "pub token", paths = ["crates/ontoref-daemon/src/session.rs"], must_be_empty = true },
rationale = "Exposing bearer tokens in list responses would allow admins to impersonate other sessions. The session.id field is a second UUID v4, safe to expose.",
},
{
@ -93,11 +89,7 @@ d.make_adr {
claim = "POST /sessions must not require authentication — it is the credential exchange endpoint",
scope = "crates/ontoref-daemon/src/api.rs",
severity = 'Hard,
check = 'Grep {
pattern = "require_session|check_primary_auth",
paths = ["crates/ontoref-daemon/src/api.rs"],
must_be_empty = false,
},
check = { tag = 'Grep, pattern = "require_session|check_primary_auth", paths = ["crates/ontoref-daemon/src/api.rs"], must_be_empty = false },
rationale = "Requiring auth to obtain auth is a bootstrap deadlock. Rate-limiting on failure is the correct mitigation, not pre-authentication.",
},
{
@ -105,11 +97,7 @@ d.make_adr {
claim = "PUT /projects/{slug}/keys must call revoke_all_for_slug before persisting new keys",
scope = "crates/ontoref-daemon/src/api.rs",
severity = 'Hard,
check = 'Grep {
pattern = "revoke_all_for_slug",
paths = ["crates/ontoref-daemon/src/api.rs"],
must_be_empty = false,
},
check = { tag = 'Grep, pattern = "revoke_all_for_slug", paths = ["crates/ontoref-daemon/src/api.rs"], must_be_empty = false },
rationale = "Sessions authenticated against the old key set become invalid after rotation. Failing to revoke them would leave stale sessions with elevated access.",
},
{
@ -117,11 +105,7 @@ d.make_adr {
claim = "All CLI HTTP calls to the daemon must use bearer-args from store.nu — no hardcoded curl without auth args",
scope = "reflection/modules/store.nu, reflection/bin/ontoref.nu",
severity = 'Soft,
check = 'Grep {
pattern = "bearer-args|http-get|http-post-json|http-delete",
paths = ["reflection/modules/store.nu"],
must_be_empty = false,
},
check = { tag = 'Grep, pattern = "bearer-args|http-get|http-post-json|http-delete", paths = ["reflection/modules/store.nu"], must_be_empty = false },
rationale = "ONTOREF_TOKEN is the single credential source for CLI. Direct curl without bearer-args bypasses the auth model silently.",
},
],

View File

@ -53,11 +53,7 @@ d.make_adr {
claim = "String interpolations in ontoref.nu must not use `(identifier: expr)` patterns — use bare `identifier: (expr)` instead",
scope = "ontoref (reflection/bin/ontoref.nu, all .nu files)",
severity = 'Hard,
check = 'Grep {
pattern = "\\([a-z_]+: \\(",
paths = ["reflection/bin/ontoref.nu"],
must_be_empty = true,
},
check = { tag = 'Grep, pattern = "\\([a-z_]+: \\(", paths = ["reflection/bin/ontoref.nu"], must_be_empty = true },
rationale = "Nushell 0.111 parses (identifier: expr) inside $\"...\" as a command call. The fix pattern (bare label + variable interpolation) is equivalent visually and immune to this parser behaviour.",
},
{
@ -65,11 +61,7 @@ d.make_adr {
claim = "Print statements with no variable interpolation must use plain strings, not `$\"...\"`",
scope = "ontoref (all .nu files)",
severity = 'Soft,
check = 'Grep {
pattern = "\\$\"[^%(]*\"",
paths = ["reflection"],
must_be_empty = true,
},
check = { tag = 'Grep, pattern = "\\$\"[^%(]*\"", paths = ["reflection"], must_be_empty = true },
rationale = "Zero-interpolation `$\"...\"` strings are fragile against future parser changes and mislead readers into expecting variable substitution.",
},
],

View File

@ -61,11 +61,7 @@ d.make_adr {
claim = "Every public HTTP handler in ontoref-daemon must carry #[onto_api(...)]",
scope = "ontoref-daemon (crates/ontoref-daemon/src/api.rs, crates/ontoref-daemon/src/sync.rs)",
severity = 'Hard,
check = 'Grep {
pattern = "#\\[onto_api",
paths = ["crates/ontoref-daemon/src/api.rs", "crates/ontoref-daemon/src/sync.rs"],
must_be_empty = false,
},
check = { tag = 'Grep, pattern = "#\\[onto_api", paths = ["crates/ontoref-daemon/src/api.rs", "crates/ontoref-daemon/src/sync.rs"], must_be_empty = false },
rationale = "catalog() is only as complete as the set of annotated handlers. Unannotated handlers are invisible to agents, CLI, and the web UI — equivalent to undocumented and unauditable routes.",
},
{
@ -73,11 +69,7 @@ d.make_adr {
claim = "inventory must remain a workspace dependency gated behind the 'catalog' feature of ontoref-derive; ontoref-ontology must not depend on inventory",
scope = "ontoref-ontology (Cargo.toml), ontoref-derive (Cargo.toml)",
severity = 'Hard,
check = 'Grep {
pattern = "inventory",
paths = ["crates/ontoref-ontology/Cargo.toml"],
must_be_empty = true,
},
check = { tag = 'Grep, pattern = "inventory", paths = ["crates/ontoref-ontology/Cargo.toml"], must_be_empty = true },
rationale = "ontoref-ontology is the zero-dep adoption surface (ADR-001). Adding inventory — even as an optional dep — violates that contract and makes protocol adoption heavier for downstream crates that only need typed NCL loading.",
},
],

View File

@ -0,0 +1,93 @@
let d = import "adr-defaults.ncl" in
d.make_adr {
id = "adr-008",
title = "NCL-First Config Validation and Override-Layer Mutation",
status = 'Accepted,
date = "2026-03-26",
context = "The config surface feature adds per-project config introspection and mutation to ontoref-daemon. Two design questions arise: (1) Where does config field validation live — in NCL contracts, in Rust struct validation, or both? (2) How does a PUT /config/{section} request mutate a project's config without corrupting the source NCL files? Direct mutation via nickel export → JSON → write-back destroys NCL comments, contract annotations, and section merge structure. Duplicating validation in both NCL and Rust creates two sources of truth with guaranteed divergence. The config surface spans ontoref's own .ontoref/config.ncl and all consumer-project configs, making the choice of validation ownership a protocol-level constraint.",
decision = "NCL contracts (std.contract.from_validator) are the single validation layer for all config fields. Rust serde structs are contract-trusted readers: they carry #[serde(default)] and consume pre-validated JSON from nickel export — no validator(), no custom Deserialize, no duplicate field constraints. Config mutation via PUT /projects/{slug}/config/{section} never modifies the original NCL source files. Instead it writes a {section}.overrides.ncl file containing only the changed fields plus a _overrides_meta audit record (actor, reason, timestamp, previous values), then appends a single idempotent import line to the entry-point NCL (using NCL's & merge operator so the override wins). nickel export validates the merged result against the section's declared contract before the mutation is committed; validation failure reverts the override file and returns the nickel error verbatim. Ontoref demonstrates this pattern on itself: .ontoref/contracts.ncl declares LogConfig and DaemonConfig contracts applied in .ontoref/config.ncl.",
rationale = [
{
claim = "NCL contracts are the correct validation boundary",
detail = "nickel export runs the contract check before any JSON reaches Rust. A value that violates LogLevel (must be one of error/warn/info/debug/trace) is rejected by nickel with a precise error message pointing to the exact field and contract. If Rust also validates, the two validators must stay in sync forever — and will diverge. Concentrating validation in NCL means the contract file is the authoritative spec for both the schema documentation and the runtime constraint.",
},
{
claim = "Override-layer mutation preserves NCL structure integrity",
detail = "A round-trip of nickel export → JSON → overwrite produces a file with no comments, no contract annotations, no merge structure, and no section rationale. The override layer avoids this entirely: the source file is immutable, the override file contains only changed fields, and NCL's & merge operator applies them at export time. The override file is git-versioned, human-readable, and revertable by deletion. nickel export on the merged entry point validates the result through the declared contract — the same validation that runs in production.",
},
{
claim = "Audit metadata in _overrides_meta closes the mutation traceability gap",
detail = "Each override file carries a top-level _overrides_meta record with managed_by = 'ontoref', created_at, and an entries array (field, from, to, reason, actor, ts). This record is consumed by GET /config/quickref to render an override history timeline and by GET /config/coherence to flag fields whose current value differs from the contract default. The metadata is a first-class NCL record — not a comment — so it survives export and is queryable by agents.",
},
],
consequences = {
positive = [
"Config field constraints are documented once (NCL contract) and enforced at the nickel export boundary — no duplication",
"Original NCL source files are immutable under daemon operation — diffs are clean, history is readable",
"nickel export validates override correctness before committing — contract violations return verbatim nickel errors to the caller",
"Override files are deletable to revert — no migration needed",
"_overrides_meta enables GET /config/quickref to render full change history with reasons",
"Ontoref's own .ontoref/contracts.ncl serves as a working example for consumer projects adopting the pattern",
],
negative = [
"Override layer requires the entry-point NCL to use & merge operators; SingleFile configs without section-level imports need a one-time restructure before overrides work",
"nickel export is the validation gate — validation errors are nickel syntax, not structured JSON; callers must parse nickel error output to surface friendly messages",
"#[serde(default)] on all Rust config structs means a missing NCL field silently uses the Rust default instead of erroring; the NCL contract (with its own defaults) is the intended fallback, not Rust",
],
},
alternatives_considered = [
{
option = "Duplicate validation in Rust (validator crate or custom Deserialize)",
why_rejected = "Two validators for the same field inevitably diverge. The NCL contract is already the authoritative schema for documentation, MCP export, and quickref generation — adding a Rust duplicate makes it decorative. validator crate adds 8+ transitive dependencies and requires annotation churn across every config struct.",
},
{
option = "Direct NCL file mutation (read → merge JSON → overwrite)",
why_rejected = "nickel export → JSON write-back destroys comments, contract annotations (| C.LogConfig), section merge structure, and in-file rationale. The resulting file is syntactically valid but semantically impoverished. Once a file is overwritten this way, the original structure cannot be recovered from git history if the file was also changed manually between sessions.",
},
{
option = "Separate config store (JSON or TOML side-file)",
why_rejected = "A side-file in a different format bypasses NCL type safety entirely — the merge operator and contract validation no longer apply. The daemon would need a custom merge algorithm to reconcile the side-file with the source NCL, and agents would need to understand two config representations. NCL's & merge operator is purpose-built for this use case.",
},
],
constraints = [
{
id = "override-layer-only",
claim = "The daemon must never write to original NCL config source files during a PUT /config/{section} mutation; only {section}.overrides.ncl may be created or modified",
scope = "crates/ontoref-daemon/src/api.rs (config mutation endpoints)",
severity = 'Hard,
check = { tag = 'Grep, pattern = "overrides\\.ncl", paths = ["crates/ontoref-daemon/src/api.rs"], must_be_empty = false },
rationale = "Immutability of source files is the safety contract the override layer provides. Violating it removes the ability to revert by deletion and breaks git diff clarity.",
},
{
id = "ncl-first-validation",
claim = "Rust config structs must not implement custom field validation; all field constraints live in NCL contracts applied before JSON reaches Rust",
scope = "crates/ontoref-daemon/src/config.rs",
severity = 'Hard,
check = { tag = 'Grep, pattern = "impl.*Validate|#\\[validate", paths = ["crates/ontoref-daemon/src/config.rs"], must_be_empty = true },
rationale = "Duplicate validation creates two diverging sources of truth. NCL contracts with std.contract.from_validator are the specified validation layer; Rust structs are downstream consumers of pre-validated data.",
},
{
id = "overrides-meta-required",
claim = "Every {section}.overrides.ncl file written by the daemon must contain a top-level _overrides_meta record with managed_by, created_at, and entries fields",
scope = "crates/ontoref-daemon/src/api.rs (override file generation)",
severity = 'Soft,
check = { tag = 'Grep, pattern = "_overrides_meta", paths = ["crates/ontoref-daemon/src/api.rs"], must_be_empty = false },
rationale = "Without audit metadata the override file is opaque — no way to surface change history in quickref or trace who changed what and why. Soft because deletion-based revert remains available even without metadata.",
},
],
related_adrs = ["adr-002", "adr-007"],
ontology_check = {
decision_string = "NCL contracts (std.contract.from_validator) are the single validation gate; Rust structs are contract-trusted with #[serde(default)]; config mutation uses override-layer files, never modifying original NCL sources",
invariants_at_risk = ["dag-formalized", "protocol-not-runtime"],
verdict = 'Safe,
},
}

View File

@ -38,6 +38,7 @@ reqwest = { workspace = true }
tokio-stream = { version = "0.1", features = ["sync"] }
inventory = { workspace = true }
ontoref-derive = { path = "../ontoref-derive" }
ontoref-ontology = { path = "../ontoref-ontology", features = ["derive"] }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }

View File

@ -340,7 +340,29 @@ pub fn router(state: AppState) -> axum::Router {
)
// Project registry management.
.route("/projects", get(projects_list).post(project_add))
.route("/projects/{slug}", delete(project_delete));
.route("/projects/{slug}", delete(project_delete))
// Config surface — read
.route("/projects/{slug}/config", get(project_config))
.route("/projects/{slug}/config/schema", get(project_config_schema))
.route(
"/projects/{slug}/config/coherence",
get(project_config_coherence),
)
.route(
"/projects/{slug}/config/quickref",
get(project_config_quickref),
)
.route(
"/projects/{slug}/config/{section}",
get(project_config_section),
)
// Config surface — cross-project comparison (no slug)
.route("/config/cross-project", get(config_cross_project))
// Config surface — mutation via override layer (admin only)
.route(
"/projects/{slug}/config/{section}",
put(project_config_update),
);
// Session endpoints — gated on ui feature (requires SessionStore).
#[cfg(feature = "ui")]
@ -2257,6 +2279,707 @@ async fn project_file_versions(
.into_response()
}
// ── Config surface endpoints
// ──────────────────────────────────────────────────
/// Resolve a project context or return 404.
macro_rules! require_project {
($state:expr, $slug:expr) => {
match $state.registry.get(&$slug) {
Some(ctx) => ctx,
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("project '{}' not registered", $slug)})),
)
.into_response()
}
}
};
}
/// Resolve the config surface for a project or return 404.
macro_rules! require_config_surface {
($ctx:expr, $slug:expr) => {
match &$ctx.config_surface {
Some(s) => s.clone(),
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("project '{}' has no config_surface in manifest.ncl", $slug)})),
)
.into_response()
}
}
};
}
#[ontoref_derive::onto_api(
method = "GET",
path = "/projects/{slug}/config",
description = "Full config export for a registered project (merged with any active overrides)",
auth = "none",
actors = "agent, developer",
params = "slug:string:required:Project slug",
tags = "config"
)]
async fn project_config(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> impl IntoResponse {
state.touch_activity();
let ctx = require_project!(state, slug);
let surface = require_config_surface!(ctx, slug);
let entry = surface.entry_point_path(&ctx.root);
match ctx.cache.export(&entry, ctx.import_path.as_deref()).await {
Ok((json, _)) => Json(json).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
#[ontoref_derive::onto_api(
method = "GET",
path = "/projects/{slug}/config/schema",
description = "Config surface schema: sections with descriptions, rationales, contracts, and \
declared consumers",
auth = "none",
actors = "agent, developer",
params = "slug:string:required:Project slug",
tags = "config"
)]
async fn project_config_schema(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> impl IntoResponse {
state.touch_activity();
let ctx = require_project!(state, slug);
let surface = require_config_surface!(ctx, slug);
let sections: Vec<serde_json::Value> = surface
.sections
.iter()
.map(|s| {
serde_json::json!({
"id": s.id,
"file": s.file,
"contract": s.contract,
"description": s.description,
"rationale": s.rationale,
"mutable": s.mutable,
"consumers": s.consumers.iter().map(|c| serde_json::json!({
"id": c.id,
"kind": format!("{:?}", c.kind),
"ref": c.reference,
"fields": c.fields,
})).collect::<Vec<_>>(),
})
})
.collect();
Json(serde_json::json!({
"slug": slug,
"config_root": surface.config_root.display().to_string(),
"entry_point": surface.entry_point,
"kind": format!("{:?}", surface.kind),
"contracts_path": surface.contracts_path,
"sections": sections,
}))
.into_response()
}
#[ontoref_derive::onto_api(
method = "GET",
path = "/projects/{slug}/config/{section}",
description = "Values for a single config section (from the merged NCL export)",
auth = "none",
actors = "agent, developer",
params = "slug:string:required:Project slug; section:string:required:Section id",
tags = "config"
)]
async fn project_config_section(
State(state): State<AppState>,
Path((slug, section)): Path<(String, String)>,
) -> impl IntoResponse {
state.touch_activity();
let ctx = require_project!(state, slug);
let surface = require_config_surface!(ctx, slug);
if surface.section(&section).is_none() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("section '{section}' not declared in config_surface")})),
)
.into_response();
}
let entry = surface.entry_point_path(&ctx.root);
match ctx.cache.export(&entry, ctx.import_path.as_deref()).await {
Ok((json, _)) => {
let val = json
.get(&section)
.cloned()
.unwrap_or(serde_json::Value::Null);
Json(serde_json::json!({"slug": slug, "section": section, "values": val}))
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
#[ontoref_derive::onto_api(
method = "GET",
path = "/projects/{slug}/config/coherence",
description = "Multi-consumer coherence report: unclaimed NCL fields, consumer field \
mismatches",
auth = "none",
actors = "agent, developer",
params = "slug:string:required:Project slug; section:string:optional:Filter to one section",
tags = "config"
)]
async fn project_config_coherence(
State(state): State<AppState>,
Path(slug): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
state.touch_activity();
let ctx = require_project!(state, slug);
let surface = require_config_surface!(ctx, slug);
let section_filter = q.get("section").map(String::as_str);
let report = crate::config_coherence::check_project(
&slug,
&surface,
&ctx.root,
&ctx.cache,
ctx.import_path.as_deref(),
section_filter,
)
.await;
Json(serde_json::to_value(&report).unwrap_or(serde_json::Value::Null)).into_response()
}
#[ontoref_derive::onto_api(
method = "GET",
path = "/projects/{slug}/config/quickref",
description = "Generated config documentation with rationales, override history, and \
coherence status",
auth = "none",
actors = "agent, developer",
params = "slug:string:required:Project slug; section:string:optional:Filter to one section; \
format:string:optional:Output format (json|markdown)",
tags = "config"
)]
async fn project_config_quickref(
State(state): State<AppState>,
Path(slug): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
state.touch_activity();
let ctx = require_project!(state, slug);
let surface = require_config_surface!(ctx, slug);
let section_filter = q.get("section").map(String::as_str);
let quickref = crate::config_coherence::build_quickref(
&slug,
&surface,
&ctx.root,
&ctx.cache,
ctx.import_path.as_deref(),
section_filter,
)
.await;
Json(quickref).into_response()
}
fn index_section_fields(
sec_val: &serde_json::Map<String, serde_json::Value>,
section_id: &str,
slug: &str,
index: &mut std::collections::BTreeMap<(String, String), Vec<(String, serde_json::Value)>>,
) {
for (field, value) in sec_val {
if field.starts_with("_meta_") || field == "_overrides_meta" {
continue;
}
index
.entry((section_id.to_owned(), field.clone()))
.or_default()
.push((slug.to_owned(), value.clone()));
}
}
#[ontoref_derive::onto_api(
method = "GET",
path = "/config/cross-project",
description = "Compare config surfaces across all registered projects: shared values, \
conflicts, coverage gaps",
auth = "none",
actors = "agent, developer",
tags = "config"
)]
async fn config_cross_project(State(state): State<AppState>) -> impl IntoResponse {
state.touch_activity();
// Collect all registered projects that have a config_surface.
let candidates: Vec<std::sync::Arc<crate::registry::ProjectContext>> = state
.registry
.all()
.into_iter()
.filter(|ctx| ctx.config_surface.is_some())
.collect();
// Export each project's full config. NclCache makes repeated calls cheap.
// (section_id, field_path) → Vec<(slug, serde_json::Value)>
let mut field_index: std::collections::BTreeMap<
(String, String),
Vec<(String, serde_json::Value)>,
> = std::collections::BTreeMap::new();
let mut project_summaries: Vec<serde_json::Value> = Vec::new();
for ctx in &candidates {
let surface = ctx.config_surface.as_ref().unwrap();
let entry = surface.entry_point_path(&ctx.root);
let export = ctx
.cache
.export(&entry, ctx.import_path.as_deref())
.await
.ok()
.map(|(j, _)| j);
let section_ids: Vec<String> = surface.sections.iter().map(|s| s.id.clone()).collect();
if let Some(ref full) = export {
for section in &surface.sections {
let Some(sec_val) = full.get(&section.id).and_then(|v| v.as_object()) else {
continue;
};
index_section_fields(sec_val, &section.id, &ctx.slug, &mut field_index);
}
}
project_summaries.push(serde_json::json!({
"slug": ctx.slug,
"config_root": surface.config_root.display().to_string(),
"kind": format!("{:?}", surface.kind),
"sections": section_ids,
"export_ok": export.is_some(),
}));
}
// Shared values: same (section, field) present in ≥2 projects with identical
// value.
let mut shared: Vec<serde_json::Value> = Vec::new();
// Conflicts: same (section, field) with differing values across projects.
let mut conflicts: Vec<serde_json::Value> = Vec::new();
// Port collisions: numeric fields named `port` or ending in `_port`.
let mut port_collisions: Vec<serde_json::Value> = Vec::new();
for ((section_id, field), entries) in &field_index {
if entries.len() < 2 {
continue;
}
let first_val = &entries[0].1;
let all_same = entries.iter().all(|(_, v)| v == first_val);
let is_port_field = field == "port" || field.ends_with("_port");
if is_port_field {
if !all_same {
// Different ports — not necessarily a conflict, but flag them.
port_collisions.push(serde_json::json!({
"section": section_id,
"field": field,
"values": entries.iter().map(|(slug, v)| serde_json::json!({ "slug": slug, "value": v })).collect::<Vec<_>>(),
}));
}
} else if all_same {
shared.push(serde_json::json!({
"section": section_id,
"field": field,
"value": first_val,
"projects": entries.iter().map(|(s, _)| s).collect::<Vec<_>>(),
}));
} else {
conflicts.push(serde_json::json!({
"section": section_id,
"field": field,
"values": entries.iter().map(|(slug, v)| serde_json::json!({ "slug": slug, "value": v })).collect::<Vec<_>>(),
}));
}
}
// Coverage gaps: section present in some projects but not others.
let all_sections: std::collections::BTreeSet<String> =
field_index.keys().map(|(s, _)| s.clone()).collect();
let mut coverage_gaps: Vec<serde_json::Value> = Vec::new();
for section_id in &all_sections {
let present_in: Vec<String> = candidates
.iter()
.filter(|ctx| {
ctx.config_surface
.as_ref()
.map(|s| s.sections.iter().any(|sec| &sec.id == section_id))
.unwrap_or(false)
})
.map(|ctx| ctx.slug.clone())
.collect();
if present_in.len() < candidates.len() {
let absent_in: Vec<String> = candidates
.iter()
.filter(|ctx| !present_in.contains(&ctx.slug))
.map(|ctx| ctx.slug.clone())
.collect();
coverage_gaps.push(serde_json::json!({
"section": section_id,
"present_in": present_in,
"absent_in": absent_in,
}));
}
}
Json(serde_json::json!({
"projects": project_summaries,
"shared_values": shared,
"conflicts": conflicts,
"port_report": port_collisions,
"coverage_gaps": coverage_gaps,
"total_projects": candidates.len(),
}))
.into_response()
}
#[derive(Deserialize)]
pub struct ConfigUpdateRequest {
/// JSON object with fields to set in this section.
pub values: serde_json::Value,
/// Reason for the change — written as a comment in the override file.
#[serde(default)]
pub reason: String,
/// When true (default for safety): return the proposed override NCL
/// without writing to disk.
#[serde(default = "default_dry_run")]
pub dry_run: bool,
}
fn default_dry_run() -> bool {
true
}
#[ontoref_derive::onto_api(
method = "PUT",
path = "/projects/{slug}/config/{section}",
description = "Mutate a config section via the override layer. dry_run=true (default) returns \
the proposed change without writing.",
auth = "admin",
actors = "agent, developer",
params = "slug:string:required:Project slug; section:string:required:Section id",
tags = "config"
)]
async fn project_config_update(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path((slug, section)): Path<(String, String)>,
Json(req): Json<ConfigUpdateRequest>,
) -> impl IntoResponse {
state.touch_activity();
let ctx = require_project!(state, slug);
// Require admin auth if the project has keys.
if ctx.auth_enabled() {
let bearer = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::trim)
.filter(|s| !s.is_empty());
match bearer {
None => {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Authorization: Bearer <password> required"})),
)
.into_response();
}
Some(password) => match ctx.verify_key(password).map(|m| m.role) {
Some(crate::registry::Role::Admin) => {}
Some(crate::registry::Role::Viewer) => {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({"error": "admin role required to mutate config"})),
)
.into_response();
}
None => {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "invalid credentials"})),
)
.into_response();
}
},
}
}
let surface = require_config_surface!(ctx, slug);
// TypeDialog projects manage their own write pipeline (form.toml →
// validators → fragments). The NCL override layer is not applicable.
if matches!(surface.kind, crate::registry::ConfigKind::TypeDialog) {
return (
StatusCode::METHOD_NOT_ALLOWED,
Json(serde_json::json!({
"error": "TypeDialog config surfaces are not mutable via this endpoint",
"detail": "TypeDialog projects use form.toml + validators + fragments. \
Mutate values through the TypeDialog pipeline directly.",
"kind": "TypeDialog",
})),
)
.into_response();
}
let section_meta = match surface.section(&section) {
Some(s) => s.clone(),
None => return (
StatusCode::NOT_FOUND,
Json(
serde_json::json!({"error": format!("section '{section}' not in config_surface")}),
),
)
.into_response(),
};
if !section_meta.mutable {
return (
StatusCode::METHOD_NOT_ALLOWED,
Json(serde_json::json!({
"error": format!("section '{section}' is marked immutable in manifest.ncl"),
"detail": "Set mutable = true in the config_section declaration to enable writes.",
})),
)
.into_response();
}
if ctx.push_only {
// push_only projects never have writable local files.
return generate_override_diff(&surface, &section, &req, &ctx.root).into_response();
}
if req.dry_run {
return generate_override_diff(&surface, &section, &req, &ctx.root).into_response();
}
// Apply the override: write {section}.overrides.ncl, patch entry point,
// validate with nickel export, revert on failure.
match apply_config_override(
&surface,
&section,
&req,
&ctx.root,
&ctx.cache,
ctx.import_path.as_deref(),
)
.await
{
Ok(result) => Json(result).into_response(),
Err(e) => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
/// Generate a dry-run diff as a `serde_json::Value` (shared by HTTP and MCP).
pub fn generate_override_diff_value(
surface: &crate::registry::ConfigSurface,
section: &str,
req: &ConfigUpdateRequest,
project_root: &std::path::Path,
) -> serde_json::Value {
let overrides_dir = surface.resolved_overrides_dir();
let override_path = project_root
.join(overrides_dir)
.join(format!("{section}.overrides.ncl"));
let ncl_content = render_override_ncl(section, &req.values, &req.reason, project_root);
serde_json::json!({
"dry_run": true,
"section": section,
"override_file": override_path.display().to_string(),
"proposed_ncl": ncl_content,
"values": req.values,
})
}
/// Generate a dry-run HTTP response showing what the override file would look
/// like.
fn generate_override_diff(
surface: &crate::registry::ConfigSurface,
section: &str,
req: &ConfigUpdateRequest,
project_root: &std::path::Path,
) -> impl IntoResponse {
Json(generate_override_diff_value(
surface,
section,
req,
project_root,
))
}
/// Render the NCL content for an override file.
fn render_override_ncl(
section: &str,
values: &serde_json::Value,
reason: &str,
_project_root: &std::path::Path,
) -> String {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut lines = vec![
format!("# {section}.overrides.ncl — generated by ontoref"),
"# DO NOT edit manually — managed by ontoref config surface.".to_owned(),
"# To revert: delete this file and remove the import from the entry point.".to_owned(),
"{".to_owned(),
format!(" _overrides_meta = {{"),
format!(" managed_by = \"ontoref\","),
format!(" updated_at = {ts},"),
];
if !reason.is_empty() {
lines.push(format!(" reason = {reason:?},"));
}
lines.push(" },".to_owned());
// Emit each key as a top-level field override.
if let Some(obj) = values.as_object() {
for (key, val) in obj {
let ncl_val = json_to_ncl_literal(val);
lines.push(format!(" {section}.{key} = {ncl_val},"));
}
}
lines.push("}".to_owned());
lines.join("\n")
}
/// Best-effort conversion of a JSON value to a NCL literal.
/// Supports primitives, arrays of primitives, and nested objects.
fn json_to_ncl_literal(val: &serde_json::Value) -> String {
match val {
serde_json::Value::Null => "null".to_owned(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => format!("{s:?}"),
serde_json::Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(json_to_ncl_literal).collect();
format!("[{}]", items.join(", "))
}
serde_json::Value::Object(obj) => {
let fields: Vec<String> = obj
.iter()
.map(|(k, v)| format!(" {k} = {}", json_to_ncl_literal(v)))
.collect();
format!("{{\n{}\n}}", fields.join(",\n"))
}
}
}
/// Write the override file, patch the entry point, and validate.
/// Reverts on validation failure.
pub async fn apply_config_override(
surface: &crate::registry::ConfigSurface,
section: &str,
req: &ConfigUpdateRequest,
project_root: &std::path::Path,
cache: &crate::cache::NclCache,
import_path: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
use std::io::Write;
let overrides_dir = project_root.join(surface.resolved_overrides_dir());
let override_file = overrides_dir.join(format!("{section}.overrides.ncl"));
let entry_point = surface.entry_point_path(project_root);
let ncl_content = render_override_ncl(section, &req.values, &req.reason, project_root);
// Backup old override file if it exists.
let backup = override_file.with_extension("ncl.bak");
if override_file.exists() {
std::fs::copy(&override_file, &backup)?;
}
// Write the override file.
{
let mut f = std::fs::File::create(&override_file)?;
f.write_all(ncl_content.as_bytes())?;
}
// Patch entry point to import the override if not already present.
let override_import = format!("(import \"./{section}.overrides.ncl\")");
let entry_original = std::fs::read_to_string(&entry_point)?;
let patched = if !entry_original.contains(&override_import) {
// Append import at the end — NCL merge chain, override wins.
Some(format!("{entry_original}\n& {override_import}\n"))
} else {
None
};
if let Some(ref new_content) = patched {
std::fs::write(&entry_point, new_content)?;
}
// Validate by re-exporting the entry point.
cache.invalidate_file(&override_file);
cache.invalidate_file(&entry_point);
match cache.export(&entry_point, import_path).await {
Ok(_) => {
// Clean up backup on success.
let _ = std::fs::remove_file(&backup);
Ok(serde_json::json!({
"applied": true,
"section": section,
"override_file": override_file.display().to_string(),
"values": req.values,
}))
}
Err(e) => {
// Revert: restore backup or remove the override file.
if backup.exists() {
let _ = std::fs::copy(&backup, &override_file);
let _ = std::fs::remove_file(&backup);
} else {
let _ = std::fs::remove_file(&override_file);
}
// Restore entry point if we patched it.
if patched.is_some() {
let _ = std::fs::write(&entry_point, &entry_original);
}
cache.invalidate_file(&override_file);
cache.invalidate_file(&entry_point);
anyhow::bail!("nickel export validation failed after override: {e}")
}
}
}
/// Exchange a key for a session token.
///
/// Accepts project keys (looked up by slug) or the daemon admin password.
@ -2615,6 +3338,7 @@ mod tests {
stale_actor_timeout: 300,
max_notifications: 100,
ack_required: vec![],
config_surface: None,
});
let ctx = Arc::new(ctx);
let actors = Arc::clone(&ctx.actors);

View File

@ -0,0 +1,220 @@
/// Typed representation of `.ontoref/config.ncl`.
///
/// Every section that the daemon reads from its own config file is captured
/// here with `#[derive(ConfigFields)]` so that `inventory` registers the
/// consumed fields at link time. The coherence endpoint can then compare
/// these registrations against the live NCL export without needing a
/// hand-maintained `fields` list in `manifest.ncl`.
///
/// All fields carry `#[serde(default)]` — a project may omit any section and
/// the daemon degrades gracefully rather than failing to start.
use ontoref_ontology::ConfigFields;
use serde::Deserialize;
/// Full deserialized view of `.ontoref/config.ncl`.
#[derive(Debug, Deserialize, Default)]
pub struct DaemonNclConfig {
#[serde(default)]
pub nickel_import_paths: Vec<String>,
#[serde(default)]
pub ui: UiConfig,
#[serde(default)]
pub log: LogConfig,
#[serde(default)]
pub mode_run: ModeRunConfig,
#[serde(default)]
pub nats_events: NatsEventsConfig,
#[serde(default)]
pub actor_init: Vec<ActorInit>,
#[serde(default)]
pub quick_actions: Vec<QuickAction>,
#[serde(default)]
pub daemon: DaemonRuntimeConfig,
#[cfg(feature = "db")]
#[serde(default)]
pub db: DbConfig,
}
/// `ui` section — template and asset paths, optional TLS cert overrides.
#[derive(Debug, Deserialize, Default, ConfigFields)]
#[config_section(id = "ui", ncl_file = ".ontoref/config.ncl")]
pub struct UiConfig {
#[serde(default)]
pub templates_dir: String,
#[serde(default)]
pub public_dir: String,
#[serde(default)]
pub tls_cert: String,
#[serde(default)]
pub tls_key: String,
#[serde(default)]
pub logo: String,
#[serde(default)]
pub logo_dark: String,
}
/// `log` section — structured logging policy.
#[derive(Debug, Deserialize, ConfigFields)]
#[config_section(id = "log", ncl_file = ".ontoref/config.ncl")]
pub struct LogConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default = "default_log_path")]
pub path: String,
#[serde(default = "default_rotation")]
pub rotation: String,
#[serde(default)]
pub compress: bool,
#[serde(default = "default_log_archive")]
pub archive: String,
#[serde(default = "default_max_files")]
pub max_files: u32,
}
impl Default for LogConfig {
fn default() -> Self {
Self {
level: default_log_level(),
path: default_log_path(),
rotation: default_rotation(),
compress: false,
archive: default_log_archive(),
max_files: default_max_files(),
}
}
}
fn default_log_level() -> String {
"info".into()
}
fn default_log_path() -> String {
"logs".into()
}
fn default_rotation() -> String {
"daily".into()
}
fn default_log_archive() -> String {
"logs-archive".into()
}
fn default_max_files() -> u32 {
7
}
/// `nats_events` section — NATS JetStream integration.
#[derive(Debug, Deserialize, Default, ConfigFields)]
#[config_section(id = "nats_events", ncl_file = ".ontoref/config.ncl")]
pub struct NatsEventsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_nats_url")]
pub url: String,
#[serde(default)]
pub emit: Vec<String>,
#[serde(default)]
pub subscribe: Vec<String>,
#[serde(default)]
pub handlers_dir: String,
#[serde(default)]
pub nkey_seed: Option<String>,
#[serde(default)]
pub require_signed_messages: bool,
#[serde(default)]
pub trusted_nkeys: Vec<String>,
#[serde(default)]
pub streams_config: String,
}
fn default_nats_url() -> String {
"nats://localhost:4222".into()
}
/// `mode_run` section — per-actor mode execution ACL.
#[derive(Debug, Deserialize, Default, ConfigFields)]
#[config_section(id = "mode_run", ncl_file = ".ontoref/config.ncl")]
pub struct ModeRunConfig {
#[serde(default)]
pub rules: Vec<ModeRunRule>,
}
#[derive(Debug, Deserialize, Default)]
pub struct ModeRunRule {
#[serde(default)]
pub when: ModeRunWhen,
#[serde(default)]
pub allow: bool,
#[serde(default)]
pub reason: String,
}
#[derive(Debug, Deserialize, Default)]
pub struct ModeRunWhen {
#[serde(default)]
pub mode_id: Option<String>,
#[serde(default)]
pub actor: Option<String>,
}
/// One entry in the `actor_init` array.
#[derive(Debug, Deserialize, Default)]
pub struct ActorInit {
#[serde(default)]
pub actor: String,
#[serde(default)]
pub mode: String,
#[serde(default)]
pub auto_run: bool,
}
/// One entry in the `quick_actions` array.
#[derive(Debug, Deserialize, Default)]
pub struct QuickAction {
#[serde(default)]
pub id: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub icon: String,
#[serde(default)]
pub category: String,
#[serde(default)]
pub mode: String,
#[serde(default)]
pub actors: Vec<String>,
}
/// `daemon` section — overrides for CLI defaults set at startup.
#[derive(Debug, Deserialize, Default, ConfigFields)]
#[config_section(id = "daemon", ncl_file = ".ontoref/config.ncl")]
pub struct DaemonRuntimeConfig {
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub idle_timeout: Option<u64>,
#[serde(default)]
pub invalidation_interval: Option<u64>,
#[serde(default)]
pub actor_sweep_interval: Option<u64>,
#[serde(default)]
pub actor_stale_timeout: Option<u64>,
#[serde(default)]
pub max_notifications: Option<usize>,
#[serde(default)]
pub notification_ack_required: Vec<String>,
}
/// `db` section — SurrealDB connection (feature-gated).
#[cfg(feature = "db")]
#[derive(Debug, Deserialize, Default, ConfigFields)]
#[config_section(id = "db", ncl_file = ".ontoref/config.ncl")]
pub struct DbConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub url: String,
#[serde(default)]
pub namespace: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
}

View File

@ -0,0 +1,375 @@
//! Multi-consumer config coherence verification.
//!
//! For each section in a project's `config_surface`, this module compares the
//! fields present in the exported NCL JSON against the fields declared by each
//! consumer. A field absent from all consumers is "unclaimed" — it exists in
//! the config but nothing reads it.
//!
//! Coherence is checked from two directions:
//! - **NCL-only fields**: present in NCL, not claimed by any consumer.
//! - **Consumer-only fields**: a consumer declares a field that doesn't exist
//! in the NCL export. The consumer either references a renamed/removed field
//! or the NCL contract is incomplete.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::cache::NclCache;
use crate::registry::{ConfigSection, ConfigSurface};
/// Merge optional NCL `_meta_*` record fields into a quickref section object.
/// Only sets `rationale` when the section object currently has an empty value.
fn merge_meta_into_section(
obj: &mut serde_json::Map<String, serde_json::Value>,
meta_val: &serde_json::Value,
) {
if let Some(rationale) = meta_val.get("rationale").and_then(|v| v.as_str()) {
if obj["rationale"].as_str().unwrap_or("").is_empty() {
obj["rationale"] = serde_json::Value::String(rationale.to_owned());
}
}
if let Some(alt) = meta_val.get("alternatives_rejected") {
obj.insert("alternatives_rejected".to_owned(), alt.clone());
}
if let Some(constraints) = meta_val.get("constraints") {
obj.insert("constraints".to_owned(), constraints.clone());
}
if let Some(see_also) = meta_val.get("see_also") {
obj.insert("see_also".to_owned(), see_also.clone());
}
}
/// Status of a section's coherence check.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CoherenceStatus {
/// All fields are claimed by at least one consumer, and no consumer
/// references a field absent from the NCL export.
Ok,
/// Some fields are unclaimed or a consumer references missing fields —
/// worth reviewing but not necessarily a bug.
Warning,
/// The NCL export failed; coherence could not be checked.
Error,
}
/// Per-consumer coherence result within a section.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsumerCoherenceReport {
pub consumer_id: String,
pub kind: String,
/// Fields the consumer declared but are absent in the NCL export.
pub missing_in_ncl: Vec<String>,
/// Fields in the NCL export that this consumer doesn't declare.
/// Non-empty when the consumer has an explicit field list (not "reads
/// all").
pub extra_in_ncl: Vec<String>,
}
/// Full coherence report for one config section.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SectionCoherenceReport {
pub section_id: String,
/// All top-level keys in the NCL export for this section (excluding
/// `_meta_*` and `_overrides_meta` keys).
pub ncl_fields: Vec<String>,
pub consumers: Vec<ConsumerCoherenceReport>,
/// Fields present in NCL but claimed by no consumer.
pub unclaimed_fields: Vec<String>,
pub status: CoherenceStatus,
}
/// Coherence report for an entire project.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectCoherenceReport {
pub project_slug: String,
pub sections: Vec<SectionCoherenceReport>,
pub has_config_surface: bool,
}
impl ProjectCoherenceReport {
/// Overall status: worst status across all sections.
pub fn overall_status(&self) -> CoherenceStatus {
if self
.sections
.iter()
.any(|s| s.status == CoherenceStatus::Error)
{
CoherenceStatus::Error
} else if self
.sections
.iter()
.any(|s| s.status == CoherenceStatus::Warning)
{
CoherenceStatus::Warning
} else {
CoherenceStatus::Ok
}
}
}
/// Run coherence check for all sections of a project's config surface.
///
/// `section_filter` — if `Some`, only check this section id.
pub async fn check_project(
slug: &str,
surface: &ConfigSurface,
project_root: &Path,
cache: &NclCache,
import_path: Option<&str>,
section_filter: Option<&str>,
) -> ProjectCoherenceReport {
let sections_to_check: Vec<&ConfigSection> = surface
.sections
.iter()
.filter(|s| section_filter.is_none_or(|f| s.id == f))
.collect();
let mut section_reports = Vec::with_capacity(sections_to_check.len());
for section in sections_to_check {
let report = check_section(section, surface, project_root, cache, import_path).await;
section_reports.push(report);
}
ProjectCoherenceReport {
project_slug: slug.to_owned(),
sections: section_reports,
has_config_surface: true,
}
}
async fn check_section(
section: &ConfigSection,
surface: &ConfigSurface,
project_root: &Path,
cache: &NclCache,
import_path: Option<&str>,
) -> SectionCoherenceReport {
let ncl_path = project_root.join(&surface.config_root).join(&section.file);
let ncl_export = cache.export(&ncl_path, import_path).await;
let (json, _) = match ncl_export {
Ok(pair) => pair,
Err(e) => {
warn!(
section = %section.id,
path = %ncl_path.display(),
error = %e,
"config coherence: nickel export failed"
);
return SectionCoherenceReport {
section_id: section.id.clone(),
ncl_fields: vec![],
consumers: vec![],
unclaimed_fields: vec![],
status: CoherenceStatus::Error,
};
}
};
// Collect top-level keys for this section from the export.
// A section NCL file may export { server = { ... }, _meta_server = {...} }
// We want the section key matching section.id; if the whole file is the
// section value, use all keys.
let ncl_fields: BTreeSet<String> = extract_section_fields(&json, &section.id);
// Build consumer reports.
let mut all_claimed: BTreeSet<String> = BTreeSet::new();
let mut consumer_reports = Vec::with_capacity(section.consumers.len());
for consumer in &section.consumers {
let consumer_fields: BTreeSet<String> = if consumer.fields.is_empty() {
// Empty field list means the consumer claims all NCL fields.
all_claimed.extend(ncl_fields.iter().cloned());
consumer_reports.push(ConsumerCoherenceReport {
consumer_id: consumer.id.clone(),
kind: format!("{:?}", consumer.kind),
missing_in_ncl: vec![],
extra_in_ncl: vec![],
});
continue;
} else {
consumer.fields.iter().cloned().collect()
};
all_claimed.extend(consumer_fields.iter().cloned());
let missing_in_ncl: Vec<String> =
consumer_fields.difference(&ncl_fields).cloned().collect();
let extra_in_ncl: Vec<String> = ncl_fields.difference(&consumer_fields).cloned().collect();
consumer_reports.push(ConsumerCoherenceReport {
consumer_id: consumer.id.clone(),
kind: format!("{:?}", consumer.kind),
missing_in_ncl,
extra_in_ncl,
});
}
let unclaimed_fields: Vec<String> = ncl_fields.difference(&all_claimed).cloned().collect();
let has_missing = consumer_reports
.iter()
.any(|c| !c.missing_in_ncl.is_empty());
let status = if !unclaimed_fields.is_empty() || has_missing {
CoherenceStatus::Warning
} else {
CoherenceStatus::Ok
};
SectionCoherenceReport {
section_id: section.id.clone(),
ncl_fields: ncl_fields.into_iter().collect(),
consumers: consumer_reports,
unclaimed_fields,
status,
}
}
/// Extract the field names for a section from the NCL export JSON.
///
/// If the JSON has a top-level key matching `section_id`, returns the keys of
/// that sub-object. Otherwise treats the entire top-level object as the section
/// fields. Strips `_meta_*` and `_overrides_meta` keys from the result.
fn extract_section_fields(json: &serde_json::Value, section_id: &str) -> BTreeSet<String> {
let obj = if let Some(sub) = json.get(section_id).and_then(|v| v.as_object()) {
sub.keys().cloned().collect()
} else if let Some(top) = json.as_object() {
top.keys()
.filter(|k| !k.starts_with("_meta_") && *k != "_overrides_meta")
.cloned()
.collect()
} else {
BTreeSet::new()
};
obj
}
/// Generate a quickref document for a project's config surface.
///
/// Combines: NCL export values + manifest metadata (descriptions, rationales,
/// consumers) + override history from `_overrides_meta` + coherence status.
pub async fn build_quickref(
slug: &str,
surface: &ConfigSurface,
project_root: &Path,
cache: &NclCache,
import_path: Option<&str>,
section_filter: Option<&str>,
) -> serde_json::Value {
let entry_point = surface.entry_point_path(project_root);
let full_export = cache.export(&entry_point, import_path).await.ok();
let coherence = check_project(
slug,
surface,
project_root,
cache,
import_path,
section_filter,
)
.await;
let coherence_by_id: BTreeMap<String, &SectionCoherenceReport> = coherence
.sections
.iter()
.map(|s| (s.section_id.clone(), s))
.collect();
let sections: Vec<serde_json::Value> = surface
.sections
.iter()
.filter(|s| section_filter.is_none_or(|f| s.id == f))
.map(|section| {
let current_values = full_export
.as_ref()
.and_then(|(json, _)| json.get(&section.id))
.cloned()
.unwrap_or(serde_json::Value::Null);
// Extract _meta_{section} from the section's own NCL file.
let meta_key = format!("_meta_{}", section.id);
let section_ncl_path =
project_root.join(&surface.config_root).join(&section.file);
let meta = tokio::task::block_in_place(|| {
// Use a sync export via the cached path — avoids async recursion.
std::process::Command::new("nickel")
.args(["export", "--format", "json"])
.arg(&section_ncl_path)
.current_dir(project_root)
.output()
.ok()
.and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok())
.and_then(|j| j.get(&meta_key).cloned())
});
// Extract override history from _overrides_meta if present.
let overrides = current_values
.as_object()
.and(full_export.as_ref())
.and_then(|(j, _)| {
j.get("_overrides_meta")
.and_then(|m| m.get("entries"))
.cloned()
})
.unwrap_or(serde_json::Value::Array(vec![]));
let coh = coherence_by_id.get(&section.id);
let coherence_summary = serde_json::json!({
"unclaimed_fields": coh.map(|c| c.unclaimed_fields.as_slice()).unwrap_or(&[]),
"status": coh.map(|c| format!("{:?}", c.status)).unwrap_or_else(|| "unknown".into()),
});
let consumers: Vec<serde_json::Value> = section
.consumers
.iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"kind": format!("{:?}", c.kind),
"ref": c.reference,
"fields": c.fields,
})
})
.collect();
let mut s = serde_json::json!({
"id": section.id,
"file": section.file,
"mutable": section.mutable,
"description": section.description,
"rationale": section.rationale,
"contract": section.contract,
"current_values": current_values,
"overrides": overrides,
"consumers": consumers,
"coherence": coherence_summary,
});
if let Some(meta_val) = meta {
if let Some(obj) = s.as_object_mut() {
merge_meta_into_section(obj, &meta_val);
}
}
s
})
.collect();
serde_json::json!({
"project": slug,
"generated_at": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
"config_root": surface.config_root.display().to_string(),
"entry_point": surface.entry_point,
"kind": format!("{:?}", surface.kind),
"sections": sections,
"overall_coherence": format!("{:?}", coherence.overall_status()),
})
}

View File

@ -2,6 +2,8 @@ pub mod actors;
pub mod api;
pub mod api_catalog;
pub mod cache;
pub mod config;
pub mod config_coherence;
pub mod error;
pub mod federation;
#[cfg(feature = "mcp")]

View File

@ -16,14 +16,11 @@ use tracing::{error, info, warn};
/// Read and apply bootstrap config from stdin (ADR-004: NCL pipe bootstrap).
///
/// Reads all of stdin as JSON, applies top-level values to CLI defaults, then
/// redirects stdin to /dev/null so the daemon's event loop does not block on
/// it. Returns the parsed JSON for downstream consumers (e.g. NATS init).
/// Aborts with exit(1) if stdin is not a pipe or JSON is invalid.
/// Read and apply bootstrap config from stdin (ADR-004: NCL pipe bootstrap).
///
/// Returns the full parsed JSON. Callers extract feature-gated sections (e.g.
/// projects) after this returns.
/// Reads all of stdin as JSON, deserializes into `DaemonNclConfig` to apply
/// typed values to CLI defaults, then redirects stdin to /dev/null. Returns
/// the raw JSON so the caller can extract service-mode fields (`projects`)
/// that are not part of `DaemonNclConfig`. Aborts with exit(1) on parse
/// errors.
fn apply_stdin_config(cli: &mut Cli) -> serde_json::Value {
use std::io::{IsTerminal, Read};
@ -49,48 +46,31 @@ fn apply_stdin_config(cli: &mut Cli) -> serde_json::Value {
}
};
// daemon port
if let Some(port) = json
.get("daemon")
.and_then(|d| d.get("port"))
.and_then(|p| p.as_u64())
{
cli.port = port as u16;
let ncl: ontoref_daemon::config::DaemonNclConfig =
serde_json::from_value(json.clone()).unwrap_or_default();
if let Some(port) = ncl.daemon.port {
cli.port = port;
}
// db credentials — only applied when enabled = true
#[cfg(feature = "db")]
if let Some(db) = json.get("db").and_then(|d| d.as_object()) {
let db_enabled = db.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false);
if db_enabled && cli.db_url.is_none() {
if let Some(url) = db.get("url").and_then(|u| u.as_str()) {
if !url.is_empty() {
cli.db_url = Some(url.to_string());
if ncl.db.enabled {
if cli.db_url.is_none() && !ncl.db.url.is_empty() {
cli.db_url = Some(ncl.db.url.clone());
}
if cli.db_namespace.is_none() && !ncl.db.namespace.is_empty() {
cli.db_namespace = Some(ncl.db.namespace.clone());
}
if !ncl.db.username.is_empty() {
cli.db_username = ncl.db.username.clone();
}
if cli.db_namespace.is_none() {
if let Some(ns) = db.get("namespace").and_then(|n| n.as_str()) {
if !ns.is_empty() {
cli.db_namespace = Some(ns.to_string());
}
}
}
if let Some(user) = db.get("username").and_then(|u| u.as_str()) {
if !user.is_empty() {
cli.db_username = user.to_string();
}
}
if let Some(pass) = db.get("password").and_then(|p| p.as_str()) {
if !pass.is_empty() {
cli.db_password = pass.to_string();
}
if !ncl.db.password.is_empty() {
cli.db_password = ncl.db.password.clone();
}
}
// ui paths
#[cfg(feature = "ui")]
apply_ui_config(cli, &json);
apply_ui_config(cli, &ncl.ui);
tracing::info!("config loaded from stdin (ADR-004 NCL pipe bootstrap)");
@ -126,8 +106,13 @@ fn run_nickel_config(
}
/// Load daemon config from .ontoref/config.ncl and override CLI defaults.
/// Returns (NICKEL_IMPORT_PATH, parsed config JSON) — both optional.
fn load_config_overrides(cli: &mut Cli) -> (Option<String>, Option<serde_json::Value>) {
/// Returns (NICKEL_IMPORT_PATH, typed config) — both optional.
fn load_config_overrides(
cli: &mut Cli,
) -> (
Option<String>,
Option<ontoref_daemon::config::DaemonNclConfig>,
) {
let config_path = cli.project_root.join(".ontoref").join("config.ncl");
if !config_path.exists() {
return (None, None);
@ -154,66 +139,50 @@ fn load_config_overrides(cli: &mut Cli) -> (Option<String>, Option<serde_json::V
}
};
// Extract daemon config
if let Some(daemon) = config_json.get("daemon").and_then(|d| d.as_object()) {
if let Some(port) = daemon.get("port").and_then(|p| p.as_u64()) {
cli.port = port as u16;
let ncl: ontoref_daemon::config::DaemonNclConfig =
match serde_json::from_value(config_json.clone()) {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "config.ncl deserialization failed — using defaults");
return (None, None);
}
if let Some(timeout) = daemon.get("idle_timeout").and_then(|t| t.as_u64()) {
};
if let Some(port) = ncl.daemon.port {
cli.port = port;
}
if let Some(timeout) = ncl.daemon.idle_timeout {
cli.idle_timeout = timeout;
}
if let Some(interval) = daemon.get("invalidation_interval").and_then(|i| i.as_u64()) {
if let Some(interval) = ncl.daemon.invalidation_interval {
cli.invalidation_interval = interval;
}
if let Some(sweep) = daemon.get("actor_sweep_interval").and_then(|s| s.as_u64()) {
if let Some(sweep) = ncl.daemon.actor_sweep_interval {
cli.actor_sweep_interval = sweep;
}
if let Some(stale) = daemon.get("actor_stale_timeout").and_then(|s| s.as_u64()) {
if let Some(stale) = ncl.daemon.actor_stale_timeout {
cli.actor_stale_timeout = stale;
}
if let Some(max) = daemon.get("max_notifications").and_then(|m| m.as_u64()) {
cli.max_notifications = max as usize;
}
if let Some(ack_dirs) = daemon
.get("notification_ack_required")
.and_then(|a| a.as_array())
{
cli.notification_ack_required = ack_dirs
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
if let Some(max) = ncl.daemon.max_notifications {
cli.max_notifications = max;
}
if !ncl.daemon.notification_ack_required.is_empty() {
cli.notification_ack_required = ncl.daemon.notification_ack_required.clone();
}
// Extract db config — only when enabled = true
#[cfg(feature = "db")]
if let Some(db) = config_json.get("db").and_then(|d| d.as_object()) {
let db_enabled = db.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false);
if db_enabled {
cli.db_url = db
.get("url")
.and_then(|u| u.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string);
cli.db_namespace = db
.get("namespace")
.and_then(|n| n.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string);
if let Some(user) = db
.get("username")
.and_then(|u| u.as_str())
.filter(|s| !s.is_empty())
{
cli.db_username = user.to_string();
if ncl.db.enabled {
if cli.db_url.is_none() && !ncl.db.url.is_empty() {
cli.db_url = Some(ncl.db.url.clone());
}
if let Some(pass) = db
.get("password")
.and_then(|p| p.as_str())
.filter(|s| !s.is_empty())
{
cli.db_password = pass.to_string();
if cli.db_namespace.is_none() && !ncl.db.namespace.is_empty() {
cli.db_namespace = Some(ncl.db.namespace.clone());
}
if !ncl.db.username.is_empty() {
cli.db_username = ncl.db.username.clone();
}
if !ncl.db.password.is_empty() {
cli.db_password = ncl.db.password.clone();
}
}
@ -232,35 +201,33 @@ fn load_config_overrides(cli: &mut Cli) -> (Option<String>, Option<serde_json::V
}
}
// UI config section — only populates fields not already set via CLI.
#[cfg(feature = "ui")]
apply_ui_config(cli, &config_json);
apply_ui_config(cli, &ncl.ui);
info!("config loaded from {}", config_path.display());
// Resolve relative paths against the canonicalized project root so the
// resulting NICKEL_IMPORT_PATH is always absolute, regardless of the
// daemon's working directory.
let import_path = config_json
.get("nickel_import_paths")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
let import_path = {
let joined = ncl
.nickel_import_paths
.iter()
.map(|p| {
let candidate = std::path::Path::new(p);
let candidate = std::path::Path::new(p.as_str());
if candidate.is_absolute() {
p.to_string()
p.clone()
} else {
abs_root.join(candidate).display().to_string()
}
})
.collect::<Vec<_>>()
.join(":")
})
.filter(|s| !s.is_empty());
.join(":");
if joined.is_empty() {
None
} else {
Some(joined)
}
};
(import_path, Some(config_json))
(import_path, Some(ncl))
}
#[derive(Parser)]
@ -498,16 +465,22 @@ async fn main() {
// Bootstrap config from stdin pipe (ADR-004).
// When --config-stdin is set the stdin JSON is the authoritative config;
// the project .ontoref/config.ncl is not read.
let (nickel_import_path, loaded_config) = if cli.config_stdin {
// loaded_ncl_config is consumed by feature-gated blocks (nats, ui, db);
// the binding is intentionally unused when all three features are off.
#[allow(unused_variables)]
let (nickel_import_path, loaded_ncl_config, stdin_raw) = if cli.config_stdin {
let json = apply_stdin_config(&mut cli);
(None, Some(json))
let ncl =
serde_json::from_value::<ontoref_daemon::config::DaemonNclConfig>(json.clone()).ok();
(None, ncl, Some(json))
} else {
load_config_overrides(&mut cli)
let (ip, ncl) = load_config_overrides(&mut cli);
(ip, ncl, None)
};
// Extract registered projects from the stdin config (service mode).
let stdin_projects: Vec<ontoref_daemon::registry::RegistryEntry> = if cli.config_stdin {
loaded_config
stdin_raw
.as_ref()
.and_then(|j| j.get("projects"))
.and_then(|p| serde_json::from_value(p.clone()).ok())
@ -624,6 +597,8 @@ async fn main() {
} else {
cli.notification_ack_required.clone()
};
let primary_config_surface =
ontoref_daemon::registry::load_config_surface(&project_root, nickel_import_path.as_deref());
let primary_ctx =
ontoref_daemon::registry::make_context(ontoref_daemon::registry::ContextSpec {
slug: primary_slug.clone(),
@ -635,6 +610,7 @@ async fn main() {
stale_actor_timeout: cli.actor_stale_timeout,
max_notifications: cli.max_notifications,
ack_required,
config_surface: primary_config_surface,
});
// Alias the primary Arcs into local bindings for use before and after
@ -707,7 +683,7 @@ async fn main() {
.unwrap_or("unknown")
.to_string();
match ontoref_daemon::nats::NatsPublisher::connect(
loaded_config.as_ref(),
loaded_ncl_config.as_ref().map(|c| &c.nats_events),
project_name,
cli.port,
)
@ -1321,7 +1297,6 @@ async fn connect_db(cli: &Cli) -> Option<Arc<stratum_db::StratumDb>> {
Some(Arc::new(db))
}
#[cfg(feature = "ui")]
fn resolve_nickel_import_path(p: &str, project_root: &std::path::Path) -> String {
let c = std::path::Path::new(p);
if c.is_absolute() {
@ -1331,6 +1306,7 @@ fn resolve_nickel_import_path(p: &str, project_root: &std::path::Path) -> String
}
}
#[cfg(feature = "ui")]
fn resolve_asset_dir(project_root: &std::path::Path, config_dir: &str) -> std::path::PathBuf {
let from_root = project_root.join(config_dir);
if from_root.exists() {
@ -1353,38 +1329,20 @@ fn resolve_asset_dir(project_root: &std::path::Path, config_dir: &str) -> std::p
}
#[cfg(feature = "ui")]
fn apply_ui_config(cli: &mut Cli, config: &serde_json::Value) {
let Some(ui) = config.get("ui").and_then(|u| u.as_object()) else {
return;
};
if cli.templates_dir.is_none() {
let dir = ui
.get("templates_dir")
.and_then(|d| d.as_str())
.unwrap_or("");
if !dir.is_empty() {
cli.templates_dir = Some(resolve_asset_dir(&cli.project_root, dir));
}
}
if cli.public_dir.is_none() {
let dir = ui.get("public_dir").and_then(|d| d.as_str()).unwrap_or("");
if !dir.is_empty() {
cli.public_dir = Some(resolve_asset_dir(&cli.project_root, dir));
fn apply_ui_config(cli: &mut Cli, ui: &ontoref_daemon::config::UiConfig) {
if cli.templates_dir.is_none() && !ui.templates_dir.is_empty() {
cli.templates_dir = Some(resolve_asset_dir(&cli.project_root, &ui.templates_dir));
}
if cli.public_dir.is_none() && !ui.public_dir.is_empty() {
cli.public_dir = Some(resolve_asset_dir(&cli.project_root, &ui.public_dir));
}
#[cfg(feature = "tls")]
{
if cli.tls_cert.is_none() {
let p = ui.get("tls_cert").and_then(|d| d.as_str()).unwrap_or("");
if !p.is_empty() {
cli.tls_cert = Some(cli.project_root.join(p));
}
}
if cli.tls_key.is_none() {
let p = ui.get("tls_key").and_then(|d| d.as_str()).unwrap_or("");
if !p.is_empty() {
cli.tls_key = Some(cli.project_root.join(p));
if cli.tls_cert.is_none() && !ui.tls_cert.is_empty() {
cli.tls_cert = Some(cli.project_root.join(&ui.tls_cert));
}
if cli.tls_key.is_none() && !ui.tls_key.is_empty() {
cli.tls_key = Some(cli.project_root.join(&ui.tls_key));
}
}
}

View File

@ -229,6 +229,32 @@ struct ActionAddInput {
project: Option<String>,
}
// ── Config surface input types
// ──────────────────────────────────────────────
#[derive(Deserialize, JsonSchema, Default)]
struct ConfigReadInput {
/// Project slug. Omit to use the default project.
project: Option<String>,
/// Config section id (e.g. `"server"`). Omit to return all sections.
section: Option<String>,
}
#[derive(Deserialize, JsonSchema, Default)]
struct ConfigUpdateInput {
/// Project slug. Omit to use the default project.
project: Option<String>,
/// Section id to mutate (e.g. `"server"`).
section: String,
/// Key/value pairs to write into the override layer.
values: serde_json::Value,
/// When true (default), return the proposed diff without writing anything.
dry_run: Option<bool>,
/// Human-readable reason for this change (stored in the override audit
/// trail).
reason: Option<String>,
}
// ── Server ──────────────────────────────────────────────────────────────────────
#[derive(Clone)]
@ -276,6 +302,10 @@ impl OntoreServer {
.with_async_tool::<GuidesTool>()
.with_async_tool::<ApiCatalogTool>()
.with_async_tool::<FileVersionsTool>()
.with_async_tool::<ProjectConfigTool>()
.with_async_tool::<ConfigCoherenceTool>()
.with_async_tool::<ConfigQuickrefTool>()
.with_async_tool::<ConfigUpdateTool>()
}
fn project_ctx(&self, slug: Option<&str>) -> ProjectCtx {
@ -2555,6 +2585,309 @@ impl AsyncTool<OntoreServer> for BookmarkAddTool {
}
}
// ── Tool: project_config (read full export or single section)
// ─────────────────
struct ProjectConfigTool;
impl ToolBase for ProjectConfigTool {
type Parameter = ConfigReadInput;
type Output = serde_json::Value;
type Error = ToolError;
fn name() -> Cow<'static, str> {
"ontoref_project_config".into()
}
fn description() -> Option<Cow<'static, str>> {
Some(
"Read a project's config surface. Returns the full NCL export (merged with any active \
overrides) for all sections, or a single section when `section` is given."
.into(),
)
}
fn output_schema() -> Option<Arc<JsonObject>> {
None
}
}
impl AsyncTool<OntoreServer> for ProjectConfigTool {
async fn invoke(
service: &OntoreServer,
param: ConfigReadInput,
) -> Result<serde_json::Value, ToolError> {
debug!(tool = "project_config", project = ?param.project, section = ?param.section);
let ctx = service.project_ctx(param.project.as_deref());
let surface = service
.state
.registry
.get(
param
.project
.as_deref()
.unwrap_or(ctx.root.file_name().and_then(|n| n.to_str()).unwrap_or("")),
)
.and_then(|c| c.config_surface.clone());
let Some(surface) = surface else {
return Err(ToolError(
"project has no config_surface in manifest.ncl".into(),
));
};
let entry = surface.entry_point_path(&ctx.root);
let (full, _) = ctx
.cache
.export(&entry, ctx.import_path.as_deref())
.await
.map_err(|e| ToolError(e.to_string()))?;
if let Some(section_id) = &param.section {
Ok(full
.get(section_id)
.cloned()
.unwrap_or(serde_json::Value::Null))
} else {
Ok(full)
}
}
}
// ── Tool: config_coherence
// ──────────────────────────────────────────────────
struct ConfigCoherenceTool;
impl ToolBase for ConfigCoherenceTool {
type Parameter = ConfigReadInput;
type Output = serde_json::Value;
type Error = ToolError;
fn name() -> Cow<'static, str> {
"ontoref_config_coherence".into()
}
fn description() -> Option<Cow<'static, str>> {
Some(
"Run the multi-consumer coherence check for a project's config surface. Reports \
unclaimed NCL fields (present in export but claimed by no consumer) and consumer \
fields missing from the NCL export. Supply `section` to check one section only."
.into(),
)
}
fn output_schema() -> Option<Arc<JsonObject>> {
None
}
}
impl AsyncTool<OntoreServer> for ConfigCoherenceTool {
async fn invoke(
service: &OntoreServer,
param: ConfigReadInput,
) -> Result<serde_json::Value, ToolError> {
debug!(tool = "config_coherence", project = ?param.project, section = ?param.section);
let ctx = service.project_ctx(param.project.as_deref());
let slug = param
.project
.as_deref()
.or_else(|| ctx.root.file_name().and_then(|n| n.to_str()))
.unwrap_or("default");
let surface = service
.state
.registry
.get(slug)
.and_then(|c| c.config_surface.clone());
let Some(surface) = surface else {
return Err(ToolError(
"project has no config_surface in manifest.ncl".into(),
));
};
let report = crate::config_coherence::check_project(
slug,
&surface,
&ctx.root,
&ctx.cache,
ctx.import_path.as_deref(),
param.section.as_deref(),
)
.await;
serde_json::to_value(&report).map_err(ToolError::from)
}
}
// ── Tool: config_quickref
// ───────────────────────────────────────────────────
struct ConfigQuickrefTool;
impl ToolBase for ConfigQuickrefTool {
type Parameter = ConfigReadInput;
type Output = serde_json::Value;
type Error = ToolError;
fn name() -> Cow<'static, str> {
"ontoref_config_quickref".into()
}
fn description() -> Option<Cow<'static, str>> {
Some(
"Generate living config documentation for a project. Combines current NCL values, \
manifest rationales, per-section _meta_ records, override history, and coherence \
status into a single JSON document. Use `section` to scope to one section."
.into(),
)
}
fn output_schema() -> Option<Arc<JsonObject>> {
None
}
}
impl AsyncTool<OntoreServer> for ConfigQuickrefTool {
async fn invoke(
service: &OntoreServer,
param: ConfigReadInput,
) -> Result<serde_json::Value, ToolError> {
debug!(tool = "config_quickref", project = ?param.project, section = ?param.section);
let ctx = service.project_ctx(param.project.as_deref());
let slug = param
.project
.as_deref()
.or_else(|| ctx.root.file_name().and_then(|n| n.to_str()))
.unwrap_or("default");
let surface = service
.state
.registry
.get(slug)
.and_then(|c| c.config_surface.clone());
let Some(surface) = surface else {
return Err(ToolError(
"project has no config_surface in manifest.ncl".into(),
));
};
Ok(crate::config_coherence::build_quickref(
slug,
&surface,
&ctx.root,
&ctx.cache,
ctx.import_path.as_deref(),
param.section.as_deref(),
)
.await)
}
}
// ── Tool: config_update (override layer mutation)
// ────────────────────────────
struct ConfigUpdateTool;
impl ToolBase for ConfigUpdateTool {
type Parameter = ConfigUpdateInput;
type Output = serde_json::Value;
type Error = ToolError;
fn name() -> Cow<'static, str> {
"ontoref_config_update".into()
}
fn description() -> Option<Cow<'static, str>> {
Some(
"Mutate a config section via the override layer. Generates a \
`{section}.overrides.ncl` file that is merged after the original NCL, preserving \
comments and contracts. `dry_run` defaults to true returns the proposed diff \
without writing. Set `dry_run: false` to apply. Always include a `reason` for the \
audit trail."
.into(),
)
}
fn output_schema() -> Option<Arc<JsonObject>> {
None
}
}
impl AsyncTool<OntoreServer> for ConfigUpdateTool {
async fn invoke(
service: &OntoreServer,
param: ConfigUpdateInput,
) -> Result<serde_json::Value, ToolError> {
let dry_run = param.dry_run.unwrap_or(true);
debug!(
tool = "config_update",
project = ?param.project,
section = %param.section,
dry_run
);
let ctx = service.project_ctx(param.project.as_deref());
let slug = param
.project
.as_deref()
.or_else(|| ctx.root.file_name().and_then(|n| n.to_str()))
.unwrap_or("default");
let surface = service
.state
.registry
.get(slug)
.and_then(|c| c.config_surface.clone());
let Some(surface) = surface else {
return Err(ToolError(
"project has no config_surface in manifest.ncl".into(),
));
};
let section_meta = surface
.section(&param.section)
.ok_or_else(|| ToolError(format!("section '{}' not in config_surface", param.section)))?
.clone();
if !section_meta.mutable {
return Err(ToolError(format!(
"section '{}' is marked immutable",
param.section
)));
}
let req = crate::api::ConfigUpdateRequest {
values: param.values,
dry_run,
reason: param.reason.unwrap_or_default(),
};
if dry_run {
Ok(crate::api::generate_override_diff_value(
&surface,
&param.section,
&req,
&ctx.root,
))
} else {
crate::api::apply_config_override(
&surface,
&param.section,
&req,
&ctx.root,
&ctx.cache,
ctx.import_path.as_deref(),
)
.await
.map_err(|e| ToolError(e.to_string()))
}
}
}
/// Run the MCP server over stdin/stdout — for use as a `command`-mode MCP
/// server in Claude Desktop, Cursor, or any stdio-compatible AI client.
pub async fn serve_stdio(state: AppState) -> anyhow::Result<()> {

View File

@ -30,67 +30,31 @@ pub struct NatsPublisher {
#[cfg(feature = "nats")]
impl NatsPublisher {
/// Connect to NATS JetStream, apply topology from config, bind consumer.
/// Reads `nats_events` section from `.ontoref/config.ncl`.
/// Returns `Ok(None)` if disabled or unavailable (graceful degradation).
/// Returns `Ok(None)` when `cfg` is `None`, disabled, or unavailable.
pub async fn connect(
config: Option<&serde_json::Value>,
cfg: Option<&crate::config::NatsEventsConfig>,
project: String,
port: u16,
) -> Result<Option<Self>> {
let config = match config {
Some(v) => v.clone(),
None => return Ok(None),
let Some(nats) = cfg else {
return Ok(None);
};
let nats_section = match config.get("nats_events") {
Some(section) => section,
None => return Ok(None),
};
let enabled = nats_section
.get("enabled")
.and_then(|e| e.as_bool())
.unwrap_or(false);
if !enabled {
if !nats.enabled {
return Ok(None);
}
info!("connecting to NATS...");
let url = nats_section
.get("url")
.and_then(|u| u.as_str())
.unwrap_or("nats://localhost:4222")
.to_string();
let nkey_seed = nats_section
.get("nkey_seed")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
let require_signed = nats_section
.get("require_signed_messages")
.and_then(|r| r.as_bool())
.unwrap_or(false);
let trusted_nkeys = nats_section
.get("trusted_nkeys")
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let conn_cfg = NatsConnectionConfig {
url: url.clone(),
nkey_seed,
require_signed_messages: require_signed,
trusted_nkeys,
url: nats.url.clone(),
nkey_seed: nats.nkey_seed.clone(),
require_signed_messages: nats.require_signed_messages,
trusted_nkeys: nats.trusted_nkeys.clone(),
};
let url = nats.url.clone();
let mut stream = match tokio::time::timeout(
std::time::Duration::from_secs(3),
EventStream::connect_client(&conn_cfg),
@ -110,15 +74,8 @@ impl NatsPublisher {
info!(url = %url, "NATS connected");
// Apply topology from streams_config file declared in project config.
// Empty string → None so TopologyConfig::load falls back to NATS_STREAMS_CONFIG
// env var (set by ontoref-daemon-boot to
// ~/.config/ontoref/streams.json).
let topology_path = nats_section
.get("streams_config")
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from);
let topology_path = (!nats.streams_config.is_empty())
.then(|| std::path::PathBuf::from(&nats.streams_config));
let topology = match TopologyConfig::load(topology_path.as_deref()) {
Ok(Some(t)) => Some(t),

View File

@ -11,6 +11,103 @@ use crate::actors::ActorRegistry;
use crate::cache::NclCache;
use crate::notifications::NotificationStore;
// ── Config surface
// ────────────────────────────────────────────────────────────
/// Which process reads a config section.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConsumerKind {
RustStruct,
NuScript,
CiPipeline,
External,
}
/// A single process that reads fields from a config section.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigConsumer {
/// Identifier for this consumer (e.g. "vapora-backend", "deploy-script").
pub id: String,
pub kind: ConsumerKind,
/// Rust fully-qualified type or script path.
pub reference: String,
/// Fields this consumer reads. Empty = reads all fields.
pub fields: Vec<String>,
}
/// Describes one NCL config file and all processes that read it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigSection {
pub id: String,
/// Path to the NCL file, relative to `config_root`.
pub file: String,
/// Path to the NCL contract file. Relative to `contracts_path` or project
/// root.
pub contract: String,
pub description: String,
/// Why this section exists and why current values were chosen.
pub rationale: String,
/// When false, ontoref will only read this section, never write.
pub mutable: bool,
pub consumers: Vec<ConfigConsumer>,
}
/// How the project's config files are organised.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfigKind {
/// Multiple .ncl files merged via & operator.
NclMerge,
/// .typedialog/ structure with form.toml + validators + fragments.
TypeDialog,
/// Single monolithic .ncl file.
SingleFile,
}
/// Project-level config surface metadata — loaded once from manifest.ncl at
/// registration time and stored in `ProjectContext`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigSurface {
/// Directory containing config NCL files, relative to project root.
pub config_root: PathBuf,
/// Main NCL file (entry point for `nickel export`).
pub entry_point: String,
pub kind: ConfigKind,
/// Directory added to NICKEL_IMPORT_PATH when exporting config.
pub contracts_path: String,
/// Where ontoref writes `{section}.overrides.ncl` files.
/// Defaults to `config_root` when empty.
pub overrides_dir: PathBuf,
pub sections: Vec<ConfigSection>,
}
impl ConfigSurface {
/// Resolve the directory where override files are written.
/// Returns `overrides_dir` if set, otherwise `config_root`.
pub fn resolved_overrides_dir(&self) -> &Path {
if self.overrides_dir == PathBuf::new() {
&self.config_root
} else {
&self.overrides_dir
}
}
/// Resolve the absolute path to the config entry point given the project
/// root.
pub fn entry_point_path(&self, project_root: &Path) -> PathBuf {
project_root.join(&self.config_root).join(&self.entry_point)
}
/// Find a section by id.
pub fn section(&self, id: &str) -> Option<&ConfigSection> {
self.sections.iter().find(|s| s.id == id)
}
}
// ── Auth / keys
// ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
@ -107,6 +204,10 @@ pub struct ProjectContext {
/// on every cache invalidation for that file. Consumers compare snapshots
/// to detect which individual files changed between polls.
pub file_versions: Arc<DashMap<PathBuf, u64>>,
/// Config surface metadata loaded from the project's manifest.ncl at
/// registration time. `None` when the project's manifest has no
/// `config_surface` field or when the manifest can't be exported.
pub config_surface: Option<ConfigSurface>,
}
impl ProjectContext {
@ -181,6 +282,7 @@ impl ProjectRegistry {
stale_actor_timeout,
max_notifications,
ack_required: vec![],
config_surface: None,
});
registry.contexts.insert(entry.slug, Arc::new(ctx));
continue;
@ -200,6 +302,7 @@ impl ProjectRegistry {
};
let import_path = resolve_import_path(&entry.nickel_import_paths, &root);
let config_surface = load_config_surface(&root, import_path.as_deref());
let ctx = make_context(ContextSpec {
slug: entry.slug.clone(),
root,
@ -210,6 +313,7 @@ impl ProjectRegistry {
stale_actor_timeout,
max_notifications,
ack_required: vec![],
config_surface,
});
registry.contexts.insert(entry.slug, Arc::new(ctx));
}
@ -246,6 +350,11 @@ impl ProjectRegistry {
let ip = resolve_import_path(&entry.nickel_import_paths, &r);
(r, ip)
};
let config_surface = if entry.push_only {
None
} else {
load_config_surface(&root, import_path.as_deref())
};
let ctx = make_context(ContextSpec {
slug: entry.slug.clone(),
root,
@ -256,6 +365,7 @@ impl ProjectRegistry {
stale_actor_timeout: self.stale_actor_timeout,
max_notifications: self.max_notifications,
ack_required: vec![],
config_surface,
});
self.contexts.insert(entry.slug, Arc::new(ctx));
Ok(())
@ -321,6 +431,164 @@ pub struct ContextSpec {
pub max_notifications: usize,
/// Directories that require notification acknowledgment.
pub ack_required: Vec<String>,
/// Pre-loaded config surface from the project's manifest.ncl.
/// Pass `None` for push_only projects or when the manifest has no
/// `config_surface` field.
pub config_surface: Option<ConfigSurface>,
}
/// Attempt to load `ConfigSurface` from a project's `manifest.ncl`
/// synchronously.
///
/// Runs `nickel export` on `.ontology/manifest.ncl` and deserialises the
/// `config_surface` key. Returns `None` when:
/// - the manifest file doesn't exist
/// - 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<ConfigSurface> {
let manifest = root.join(".ontology").join("manifest.ncl");
if !manifest.exists() {
return None;
}
let mut cmd = std::process::Command::new("nickel");
cmd.args(["export", "--format", "json"])
.arg(&manifest)
.current_dir(root);
if let Some(ip) = import_path {
cmd.env("NICKEL_IMPORT_PATH", ip);
}
let output = cmd.output().ok()?;
if !output.status.success() {
warn!(
path = %manifest.display(),
stderr = %String::from_utf8_lossy(&output.stderr),
"nickel export of manifest.ncl failed — config_surface not loaded"
);
return None;
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
let surface_val = json.get("config_surface")?;
// Deserialise into an intermediate form that matches the NCL schema,
// then convert to the canonical ConfigSurface.
#[derive(Deserialize)]
struct NclConfigConsumer {
id: String,
kind: String,
#[serde(default, rename = "ref")]
reference: String,
#[serde(default)]
fields: Vec<String>,
}
#[derive(Deserialize)]
struct NclConfigSection {
id: String,
file: String,
#[serde(default)]
contract: String,
#[serde(default)]
description: String,
#[serde(default)]
rationale: String,
#[serde(default = "default_true")]
mutable: bool,
#[serde(default)]
consumers: Vec<NclConfigConsumer>,
}
fn default_true() -> bool {
true
}
#[derive(Deserialize)]
struct NclConfigSurface {
config_root: String,
#[serde(default = "default_config_ncl")]
entry_point: String,
#[serde(default = "default_ncl_merge")]
kind: String,
#[serde(default)]
contracts_path: String,
#[serde(default)]
overrides_dir: String,
#[serde(default)]
sections: Vec<NclConfigSection>,
}
fn default_config_ncl() -> String {
"config.ncl".to_string()
}
fn default_ncl_merge() -> String {
"NclMerge".to_string()
}
let ncl: NclConfigSurface = match serde_json::from_value(surface_val.clone()) {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "failed to deserialise config_surface from manifest.ncl");
return None;
}
};
let kind = match ncl.kind.as_str() {
"TypeDialog" => ConfigKind::TypeDialog,
"SingleFile" => ConfigKind::SingleFile,
_ => ConfigKind::NclMerge,
};
let config_root = root.join(&ncl.config_root);
let overrides_dir = if ncl.overrides_dir.is_empty() {
PathBuf::new()
} else {
root.join(&ncl.overrides_dir)
};
let sections = ncl
.sections
.into_iter()
.map(|s| {
let consumers = s
.consumers
.into_iter()
.map(|c| {
let consumer_kind = match c.kind.as_str() {
"NuScript" => ConsumerKind::NuScript,
"CiPipeline" => ConsumerKind::CiPipeline,
"External" => ConsumerKind::External,
_ => ConsumerKind::RustStruct,
};
ConfigConsumer {
id: c.id,
kind: consumer_kind,
reference: c.reference,
fields: c.fields,
}
})
.collect();
ConfigSection {
id: s.id,
file: s.file,
contract: s.contract,
description: s.description,
rationale: s.rationale,
mutable: s.mutable,
consumers,
}
})
.collect();
Some(ConfigSurface {
config_root,
entry_point: ncl.entry_point,
kind,
contracts_path: ncl.contracts_path,
overrides_dir,
sections,
})
}
pub fn make_context(spec: ContextSpec) -> ProjectContext {
@ -348,6 +616,7 @@ pub fn make_context(spec: ContextSpec) -> ProjectContext {
seed_lock: Arc::new(Semaphore::new(1)),
ontology_version: Arc::new(AtomicU64::new(0)),
file_versions: Arc::new(DashMap::new()),
config_surface: spec.config_surface,
}
}
@ -481,6 +750,7 @@ mod tests {
stale_actor_timeout: 300,
max_notifications: 64,
ack_required: vec![],
config_surface: None,
}))
}

View File

@ -36,10 +36,22 @@ impl IntoResponse for UiError {
UiError::Forbidden(_) => StatusCode::FORBIDDEN,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
let detail = if let UiError::Render(ref e) = self {
use std::error::Error;
let mut chain = format!("{e}");
let mut src = e.source();
while let Some(s) = src {
chain.push_str(&format!("\nCaused by: {s}"));
src = s.source();
}
chain
} else {
format!("{self}")
};
let html = format!(
r#"<!DOCTYPE html><html data-theme="dark"><body class="p-8">
<h1 class="text-2xl font-bold text-error mb-4">UI Error</h1>
<pre class="bg-base-200 p-4 rounded">{self}</pre>
<pre class="bg-base-200 p-4 rounded">{detail}</pre>
</body></html>"#
);
(status, Html(html)).into_response()
@ -58,8 +70,17 @@ pub(crate) async fn render(
ctx: &Context,
) -> Result<Html<String>, UiError> {
let guard = tera.read().await;
let html = guard.render(template, ctx)?;
Ok(Html(html))
guard.render(template, ctx).map(Html).map_err(|e| {
use std::error::Error;
let mut chain = format!("{e}");
let mut src = e.source();
while let Some(s) = src {
chain.push_str(&format!("{s}"));
src = s.source();
}
tracing::error!(template, error = %chain, "tera render failed");
UiError::Render(e)
})
}
pub(crate) fn tera_ref(state: &AppState) -> Result<&Arc<RwLock<tera::Tera>>, UiError> {
@ -915,7 +936,12 @@ pub async fn api_catalog_page_mp(
let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
let base_url = format!("/ui/{slug}");
let routes: Vec<serde_json::Value> = crate::api_catalog::catalog()
// The #[onto_api] catalog is the ontoref-daemon's own HTTP surface.
// Only expose it for the primary project (ontoref itself). Consumer
// projects have their own API surfaces not registered in this process.
let is_primary = slug == state.registry.primary_slug();
let routes: Vec<serde_json::Value> = if is_primary {
crate::api_catalog::catalog()
.into_iter()
.map(|r| {
let params: Vec<serde_json::Value> = r
@ -941,13 +967,17 @@ pub async fn api_catalog_page_mp(
"feature": r.feature,
})
})
.collect();
.collect()
} else {
vec![]
};
let catalog_json = serde_json::to_string(&routes).unwrap_or_else(|_| "[]".to_string());
let mut ctx = Context::new();
ctx.insert("catalog_json", &catalog_json);
ctx.insert("route_count", &routes.len());
ctx.insert("is_primary", &is_primary);
ctx.insert("base_url", &base_url);
ctx.insert("slug", &slug);
ctx.insert("current_role", &auth_role_str(&auth));
@ -2747,7 +2777,7 @@ async fn run_action_by_id(
}
match tokio::process::Command::new(&ontoref_bin)
.arg(&mode)
.args(["run", &mode])
.current_dir(root)
.spawn()
{
@ -2893,3 +2923,163 @@ fn resolve_bookmark_ctx(
}
(state.project_root.clone(), state.cache.clone())
}
// ── Config surface page
// ──────────────────────────────────────────────────────
pub async fn config_page_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
let base_url = format!("/ui/{slug}");
let surface = ctx_ref.config_surface.clone();
let has_config_surface = surface.is_some();
let mut ctx = Context::new();
ctx.insert("slug", &slug);
ctx.insert("base_url", &base_url);
ctx.insert("current_role", &auth_role_str(&auth));
ctx.insert("has_config_surface", &has_config_surface);
if let Some(ref surface) = surface {
ctx.insert("config_root", &surface.config_root.display().to_string());
ctx.insert("entry_point", &surface.entry_point);
ctx.insert("kind", &format!("{:?}", surface.kind));
let quickref = crate::config_coherence::build_quickref(
&slug,
surface,
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
None,
)
.await;
let overall_status = quickref
.get("overall_coherence")
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
ctx.insert("overall_status", overall_status);
let sections = quickref
.get("sections")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
ctx.insert("sections", &sections);
} else {
ctx.insert("config_root", "");
ctx.insert("entry_point", "");
ctx.insert("kind", "");
ctx.insert("overall_status", "Unknown");
ctx.insert("sections", &serde_json::Value::Array(vec![]));
}
insert_brand_ctx(
&mut ctx,
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
&base_url,
)
.await;
render(tera, "pages/config.html", &ctx).await
}
pub async fn adrs_page_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
let base_url = format!("/ui/{slug}");
let adrs_dir = ctx_ref.root.join("adrs");
let import_path = ctx_ref.import_path.clone();
let cache = ctx_ref.cache.clone();
let mut adrs: Vec<serde_json::Value> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&adrs_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
// Only keep actual ADR files: adr-NNN-* where NNN starts with a digit
let after_prefix = match stem.strip_prefix("adr-") {
Some(s) => s,
None => continue,
};
if !after_prefix.starts_with(|c: char| c.is_ascii_digit()) {
continue;
}
match cache.export(&path, import_path.as_deref()).await {
Ok((v, _)) => {
let hard_count = v
.get("constraints")
.and_then(|c| c.get("hard"))
.and_then(|h| h.as_array())
.map(|a| a.len())
.unwrap_or(0);
let soft_count = v
.get("constraints")
.and_then(|c| c.get("soft"))
.and_then(|s| s.as_array())
.map(|a| a.len())
.unwrap_or(0);
adrs.push(serde_json::json!({
"id": v.get("id").and_then(|i| i.as_str()).unwrap_or(&stem),
"title": v.get("title").and_then(|t| t.as_str()).unwrap_or(""),
"status": v.get("status").and_then(|s| s.as_str()).unwrap_or(""),
"date": v.get("date").and_then(|d| d.as_str()).unwrap_or(""),
"context": v.get("context").and_then(|c| c.as_str()).unwrap_or(""),
"decision": v.get("decision").and_then(|d| d.as_str()).unwrap_or(""),
"hard_constraints": hard_count,
"soft_constraints": soft_count,
"file": stem,
}));
}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "adrs_page: export failed");
adrs.push(serde_json::json!({
"id": stem, "title": "", "status": "Error",
"date": "", "context": "", "decision": "",
"hard_constraints": 0, "soft_constraints": 0, "file": stem,
}));
}
}
}
}
adrs.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());
let adrs_json = serde_json::to_string(&adrs).unwrap_or_else(|_| "[]".to_string());
let mut ctx = Context::new();
ctx.insert("slug", &slug);
ctx.insert("base_url", &base_url);
ctx.insert("current_role", &auth_role_str(&auth));
ctx.insert("adrs_json", &adrs_json);
ctx.insert("adr_count", &adrs.len());
insert_brand_ctx(
&mut ctx,
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
&base_url,
)
.await;
render(tera, "pages/adrs.html", &ctx).await
}

View File

@ -92,6 +92,8 @@ fn multi_router(state: AppState) -> axum::Router {
)
.route("/{slug}/compose/send", post(handlers::compose_send_mp))
.route("/{slug}/api", get(handlers::api_catalog_page_mp))
.route("/{slug}/config", get(handlers::config_page_mp))
.route("/{slug}/adrs", get(handlers::adrs_page_mp))
.route("/{slug}/actions", get(handlers::actions_page_mp))
.route("/{slug}/actions/run", post(handlers::actions_run_mp))
.route("/{slug}/qa", get(handlers::qa_page_mp))

View File

@ -16,7 +16,10 @@
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<style>
.badge-xs { height: 1rem; }
.badge-success, .badge-info, .badge-error, .badge-warning { color: #ffffff; }
html.nav-icons .nav-label { display: none !important; }
html.nav-icons .dropdown-content .nav-label { display: inline !important; }
html.nav-names .nav-icon { display: none !important; }
</style>
{% block head %}{% endblock head %}
@ -35,7 +38,7 @@
</svg>
</button>
<ul tabindex="0"
class="dropdown-content menu menu-sm bg-base-200 shadow-lg rounded-box z-50 w-52 mt-2 p-2 gap-0.5">
class="dropdown-content menu menu-sm bg-base-200 shadow-lg rounded-box z-50 w-56 mt-2 p-2 gap-0.5">
{% if slug %}
<li><a href="/ui/">← Projects</a></li>
<li class="divider my-0.5"></li>
@ -45,52 +48,98 @@
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
<span class="nav-label">Dashboard</span>
</a></li>
<li><a href="{{ base_url }}/graph" class="gap-1.5">
<!-- Reflect: Modes · Actions · Sessions -->
<li>
<details>
<summary class="gap-1.5 font-medium text-base-content/70">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span class="nav-label">Reflect</span>
</summary>
<ul>
<li><a href="{{ base_url }}/modes" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span class="nav-label">Modes</span>
</a></li>
<li><a href="{{ base_url }}/actions" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="nav-label">Graph</span>
<span class="nav-label">Actions</span>
</a></li>
<li><a href="{{ base_url }}/sessions" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span class="nav-label">Sessions</span>
</a></li>
</ul>
</details>
</li>
<!-- Track: Backlog · Q&A · Notifications -->
<li>
<details>
<summary class="gap-1.5 font-medium text-base-content/70">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/></svg>
<span class="nav-label">Track</span>
</summary>
<ul>
<li><a href="{{ base_url }}/backlog" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/></svg>
<span class="nav-label">Backlog</span>
</a></li>
<li><a href="{{ base_url }}/qa" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="nav-label">Q&amp;A</span>
</a></li>
<li><a href="{{ base_url }}/notifications" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
<span class="nav-label">Notifications</span>
</a></li>
<li><a href="{{ base_url }}/modes" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span class="nav-label">Modes</span>
</ul>
</details>
</li>
<!-- Knowledge: Graph · Search · Compose -->
<li>
<details>
<summary class="gap-1.5 font-medium text-base-content/70">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<span class="nav-label">Knowledge</span>
</summary>
<ul>
<li><a href="{{ base_url }}/graph" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="nav-label">Graph</span>
</a></li>
<li><a href="{{ base_url }}/search" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<span class="nav-label">Search</span>
</a></li>
<li><a href="{{ base_url }}/actions" class="gap-1.5 {% block mob_nav_actions %}{% endblock mob_nav_actions %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span class="nav-label">Actions</span>
</a></li>
<li><a href="{{ base_url }}/qa" class="gap-1.5 {% block mob_nav_qa %}{% endblock mob_nav_qa %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="nav-label">Q&amp;A</span>
</a></li>
<li><a href="{{ base_url }}/backlog" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/></svg>
<span class="nav-label">Backlog</span>
</a></li>
<li><a href="{{ base_url }}/compose" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
<span class="nav-label">Compose</span>
</a></li>
</ul>
</details>
</li>
<!-- Dev: API · Config -->
<li>
<details>
<summary class="gap-1.5 font-medium text-base-content/70">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
<span class="nav-label">Dev</span>
</summary>
<ul>
<li><a href="{{ base_url }}/api" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
<span class="nav-label">API</span>
</a></li>
<li><a href="{{ base_url }}/config" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span class="nav-label">Config</span>
</a></li>
<li><a href="{{ base_url }}/adrs" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="nav-label">ADRs</span>
</a></li>
</ul>
</details>
</li>
<li class="divider my-0.5"></li>
{% endif %}
{% if not slug or current_role == "admin" %}
@ -132,61 +181,110 @@
{% endif %}
</div>
<!-- Desktop: nav links -->
<!-- Desktop: nav links (work area hover dropdowns) -->
<div class="navbar-center hidden md:flex">
{% if not hide_project_nav %}
<ul class="menu menu-horizontal px-1 gap-0.5 text-sm">
<li><a href="{{ base_url }}/" class="gap-1.5 {% block nav_dashboard %}{% endblock nav_dashboard %}">
<div class="flex items-center gap-0.5 text-sm">
<!-- Dashboard: standalone -->
<a href="{{ base_url }}/" class="btn btn-ghost btn-sm gap-1.5 {% block nav_dashboard %}{% endblock nav_dashboard %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
<span class="nav-label">Dashboard</span>
</a>
<!-- Reflect: Modes · Actions · Sessions -->
<div class="dropdown dropdown-hover">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1.5 {% block nav_group_reflect %}{% endblock nav_group_reflect %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span class="nav-label">Reflect</span>
<svg class="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-200 shadow-lg rounded-box z-50 w-44 p-2 mt-1">
<li><a href="{{ base_url }}/modes" class="gap-1.5 {% block nav_modes %}{% endblock nav_modes %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span class="nav-label">Modes</span>
</a></li>
<li><a href="{{ base_url }}/graph" class="gap-1.5 {% block nav_graph %}{% endblock nav_graph %}">
<li><a href="{{ base_url }}/actions" class="gap-1.5 {% block nav_actions %}{% endblock nav_actions %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="nav-label">Graph</span>
<span class="nav-label">Actions</span>
</a></li>
<li><a href="{{ base_url }}/sessions" class="gap-1.5 {% block nav_sessions %}{% endblock nav_sessions %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span class="nav-label">Sessions</span>
</a></li>
</ul>
</div>
<!-- Track: Backlog · Q&A · Notifications -->
<div class="dropdown dropdown-hover">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1.5 {% block nav_group_track %}{% endblock nav_group_track %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/></svg>
<span class="nav-label">Track</span>
<svg class="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-200 shadow-lg rounded-box z-50 w-44 p-2 mt-1">
<li><a href="{{ base_url }}/backlog" class="gap-1.5 {% block nav_backlog %}{% endblock nav_backlog %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/></svg>
<span class="nav-label">Backlog</span>
</a></li>
<li><a href="{{ base_url }}/qa" class="gap-1.5 {% block nav_qa %}{% endblock nav_qa %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="nav-label">Q&amp;A</span>
</a></li>
<li><a href="{{ base_url }}/notifications" class="gap-1.5 {% block nav_notifications %}{% endblock nav_notifications %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
<span class="nav-label">Notifications</span>
</a></li>
<li><a href="{{ base_url }}/modes" class="gap-1.5 {% block nav_modes %}{% endblock nav_modes %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span class="nav-label">Modes</span>
</ul>
</div>
<!-- Knowledge: Graph · Search · Compose -->
<div class="dropdown dropdown-hover">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1.5 {% block nav_group_knowledge %}{% endblock nav_group_knowledge %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<span class="nav-label">Knowledge</span>
<svg class="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-200 shadow-lg rounded-box z-50 w-44 p-2 mt-1">
<li><a href="{{ base_url }}/graph" class="gap-1.5 {% block nav_graph %}{% endblock nav_graph %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="nav-label">Graph</span>
</a></li>
<li><a href="{{ base_url }}/search" class="gap-1.5 {% block nav_search %}{% endblock nav_search %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<span class="nav-label">Search</span>
</a></li>
<li><a href="{{ base_url }}/actions" class="gap-2 {% block nav_actions %}{% endblock nav_actions %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span class="nav-label">Actions</span>
</a></li>
<li><a href="{{ base_url }}/qa" class="gap-2 {% block nav_qa %}{% endblock nav_qa %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="nav-label">Q&amp;A</span>
</a></li>
<li><a href="{{ base_url }}/backlog" class="gap-1.5 {% block nav_backlog %}{% endblock nav_backlog %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/></svg>
<span class="nav-label">Backlog</span>
</a></li>
<li><a href="{{ base_url }}/compose" class="gap-1.5 {% block nav_compose %}{% endblock nav_compose %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
<span class="nav-label">Compose</span>
</a></li>
</ul>
</div>
<!-- Dev: API · Config -->
<div class="dropdown dropdown-hover">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1.5 {% block nav_group_dev %}{% endblock nav_group_dev %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
<span class="nav-label">Dev</span>
<svg class="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-200 shadow-lg rounded-box z-50 w-44 p-2 mt-1">
<li><a href="{{ base_url }}/api" class="gap-1.5 {% block nav_api %}{% endblock nav_api %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
<span class="nav-label">API</span>
</a></li>
<li><a href="{{ base_url }}/config" class="gap-1.5 {% block nav_config %}{% endblock nav_config %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span class="nav-label">Config</span>
</a></li>
<li><a href="{{ base_url }}/adrs" class="gap-1.5 {% block nav_adrs %}{% endblock nav_adrs %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="nav-label">ADRs</span>
</a></li>
</ul>
</div>
</div>
{% endif %}
</div>

View File

@ -3,6 +3,7 @@
{% block title %}Actions — Ontoref{% endblock title %}
{% block nav_actions %}active{% endblock nav_actions %}
{% block nav_group_reflect %}active{% endblock nav_group_reflect %}
{% block mob_nav_actions %}active{% endblock mob_nav_actions %}
{% block content %}

View File

@ -0,0 +1,155 @@
{% extends "base.html" %}
{% import "macros/ui.html" as m %}
{% block title %}ADRs — {{ slug }} — Ontoref{% endblock title %}
{% block nav_adrs %}active{% endblock nav_adrs %}
{% block nav_group_dev %}active{% endblock nav_group_dev %}
{% block head %}
<style>
.status-accepted { @apply badge badge-success badge-xs font-mono; }
.status-proposed { @apply badge badge-warning badge-xs font-mono; }
.status-deprecated { @apply badge badge-ghost badge-xs font-mono; }
.status-superseded { @apply badge badge-error badge-xs font-mono; }
.status-error { @apply badge badge-error badge-xs font-mono; }
</style>
{% endblock head %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Architecture Decision Records</h1>
<p class="text-base-content/50 text-sm mt-1">Typed NCL records — constraints, rationale, and alternatives for lasting architectural decisions</p>
</div>
<span class="badge badge-lg badge-neutral">{{ adr_count }} ADRs</span>
</div>
<!-- Filter bar -->
<div class="flex flex-wrap gap-2 mb-4">
<input id="filter-input" type="text" placeholder="Filter by ID, title, or context…"
class="input input-sm input-bordered flex-1 min-w-48 font-mono"
oninput="filterAdrs()">
<select id="filter-status" class="select select-sm select-bordered" onchange="filterAdrs()">
<option value="">All statuses</option>
<option value="Accepted">Accepted</option>
<option value="Proposed">Proposed</option>
<option value="Deprecated">Deprecated</option>
<option value="Superseded">Superseded</option>
</select>
</div>
<!-- ADR table -->
<div class="overflow-x-auto" id="adrs-container">
<table class="table table-sm w-full bg-base-200 rounded-lg" id="adrs-table">
<thead>
<tr class="text-base-content/50 text-xs uppercase tracking-wider">
<th class="w-24">ID</th>
<th>Title</th>
<th class="w-24">Status</th>
<th class="w-20">Date</th>
<th class="w-20">Constraints</th>
</tr>
</thead>
<tbody id="adrs-body"></tbody>
</table>
</div>
<!-- ADR detail modal -->
<dialog id="adr-modal" class="modal">
<div class="modal-box w-full max-w-3xl">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"></button>
</form>
<div class="flex items-baseline gap-3 mb-1 pr-8">
<span id="detail-id" class="font-mono font-bold text-primary"></span>
<span id="detail-status-badge"></span>
<span id="detail-date" class="text-xs text-base-content/40 font-mono"></span>
</div>
<h2 id="detail-title" class="text-lg font-bold mb-3"></h2>
<div class="space-y-4 text-sm">
<div>
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-1">Context</h3>
<p id="detail-context" class="text-base-content/80 leading-relaxed"></p>
</div>
<div>
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-1">Decision</h3>
<p id="detail-decision" class="text-base-content/80 leading-relaxed"></p>
</div>
</div>
<div class="flex gap-4 text-xs text-base-content/40 border-t border-base-content/10 pt-3 mt-4">
<span>Hard constraints: <span id="detail-hard" class="font-mono text-error"></span></span>
<span>Soft constraints: <span id="detail-soft" class="font-mono text-warning"></span></span>
<span class="ml-auto font-mono" id="detail-file"></span>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<script>
const ADRS = {{ adrs_json | safe }};
function statusClass(s) {
const map = { Accepted: 'accepted', Proposed: 'proposed', Deprecated: 'deprecated', Superseded: 'superseded', Error: 'error' };
return `status-${map[s] || 'proposed'}`;
}
function statusBadge(s) {
return `<span class="${statusClass(s)}">${s}</span>`;
}
let visibleAdrs = ADRS;
function renderAdrs(adrs) {
visibleAdrs = adrs;
const tbody = document.getElementById('adrs-body');
tbody.innerHTML = adrs.map((a, i) => `
<tr class="hover cursor-pointer" onclick="showDetail(${i})">
<td class="font-mono text-xs text-primary">${a.id}</td>
<td class="text-sm">${a.title || '<span class="text-base-content/30"></span>'}</td>
<td>${statusBadge(a.status)}</td>
<td class="font-mono text-xs text-base-content/50">${a.date || ''}</td>
<td class="text-xs">
${a.hard_constraints > 0 ? `<span class="text-error font-mono mr-1">${a.hard_constraints}H</span>` : ''}
${a.soft_constraints > 0 ? `<span class="text-warning font-mono">${a.soft_constraints}S</span>` : ''}
${a.hard_constraints === 0 && a.soft_constraints === 0 ? '<span class="text-base-content/30"></span>' : ''}
</td>
</tr>
`).join('');
}
function showDetail(index) {
const a = visibleAdrs[index];
if (!a) return;
document.getElementById('detail-id').textContent = a.id;
document.getElementById('detail-status-badge').innerHTML = statusBadge(a.status);
document.getElementById('detail-date').textContent = a.date;
document.getElementById('detail-title').textContent = a.title;
document.getElementById('detail-context').textContent = a.context;
document.getElementById('detail-decision').textContent = a.decision;
document.getElementById('detail-hard').textContent = a.hard_constraints;
document.getElementById('detail-soft').textContent = a.soft_constraints;
document.getElementById('detail-file').textContent = a.file + '.ncl';
document.getElementById('adr-modal').showModal();
}
function filterAdrs() {
const text = document.getElementById('filter-input').value.toLowerCase();
const status = document.getElementById('filter-status').value;
const filtered = ADRS.filter(a => {
const textMatch = !text ||
a.id.toLowerCase().includes(text) ||
a.title.toLowerCase().includes(text) ||
a.context.toLowerCase().includes(text);
const statusMatch = !status || a.status === status;
return textMatch && statusMatch;
});
renderAdrs(filtered);
}
renderAdrs(ADRS);
</script>
{% endblock content %}

View File

@ -3,6 +3,7 @@
{% block title %}API Catalog — Ontoref{% endblock title %}
{% block nav_api %}active{% endblock nav_api %}
{% block nav_group_dev %}active{% endblock nav_group_dev %}
{% block head %}
<style>
@ -21,11 +22,22 @@
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">API Catalog</h1>
<p class="text-base-content/50 text-sm mt-1">Annotated HTTP surface — generated from <code>#[onto_api]</code> annotations</p>
{% if is_primary %}
<p class="text-base-content/50 text-sm mt-1">Ontoref daemon HTTP surface — generated from <code>#[onto_api]</code> annotations</p>
{% else %}
<p class="text-base-content/50 text-sm mt-1">Project HTTP surface — routes declared in this project's ontology</p>
{% endif %}
</div>
<span class="badge badge-lg badge-neutral">{{ route_count }} routes</span>
</div>
{% if not is_primary and route_count == 0 %}
<div class="flex flex-col items-center justify-center py-16 text-base-content/40 text-sm">
<p>No API surface declared for <code class="font-mono">{{ slug }}</code>.</p>
<p class="mt-2">Add route declarations to the project's ontology to surface them here.</p>
</div>
{% else %}
<!-- Filter bar -->
<div class="flex flex-wrap gap-2 mb-4" id="filter-bar">
<input id="filter-input" type="text" placeholder="Filter by path or description…"
@ -64,27 +76,33 @@
</table>
</div>
<!-- Route detail panel -->
<div id="route-detail" class="hidden mt-4 p-4 bg-base-200 rounded-lg border border-base-300">
<div class="flex justify-between items-start mb-3">
<div>
<!-- Route detail modal -->
<dialog id="route-modal" class="modal">
<div class="modal-box w-full max-w-2xl">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"></button>
</form>
<div class="flex items-baseline gap-2 mb-3 pr-8">
<span id="detail-method" class="font-mono font-bold text-lg"></span>
<span id="detail-path" class="font-mono text-base-content/70 ml-2"></span>
<span id="detail-path" class="font-mono text-base-content/60 text-sm break-all"></span>
</div>
<button onclick="closeDetail()" class="btn btn-xs btn-ghost"></button>
</div>
<p id="detail-desc" class="text-sm text-base-content/80 mb-3"></p>
<div id="detail-params" class="hidden">
<p id="detail-desc" class="text-sm text-base-content/80 mb-4"></p>
<div id="detail-params" class="hidden mb-4">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-2">Parameters</h3>
<table class="table table-xs w-full">
<thead><tr class="text-base-content/40 text-xs"><th>Name</th><th>Type</th><th>Constraint</th><th>Description</th></tr></thead>
<tbody id="detail-params-body"></tbody>
</table>
</div>
<div class="mt-3 flex gap-3 text-xs text-base-content/40">
<div class="flex gap-4 text-xs text-base-content/40 border-t border-base-content/10 pt-3">
<span>Feature: <code id="detail-feature" class="font-mono"></code></span>
<span id="detail-auth-wrap">Auth: <span id="detail-auth"></span></span>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<script>
const ROUTES = {{ catalog_json | safe }};
@ -107,12 +125,13 @@ function tagBadges(tags) {
return tags.map(t => `<span class="badge badge-xs badge-outline">${t}</span>`).join(' ');
}
let activeRoute = null;
let visibleRoutes = ROUTES;
function renderRoutes(routes) {
visibleRoutes = routes;
const tbody = document.getElementById('routes-body');
tbody.innerHTML = routes.map((r, i) => `
<tr class="hover cursor-pointer route-row" data-index="${i}" onclick="showDetail(${i})">
<tr class="hover cursor-pointer route-row" onclick="showDetail(${i})">
<td class="font-mono font-bold ${methodClass(r.method)}">${r.method}</td>
<td class="font-mono text-sm">${r.path}</td>
<td class="text-sm text-base-content/70">${r.description}</td>
@ -124,13 +143,14 @@ function renderRoutes(routes) {
}
function showDetail(index) {
const r = ROUTES[index];
activeRoute = index;
const r = visibleRoutes[index];
if (!r) return;
document.getElementById('detail-method').textContent = r.method;
document.getElementById('detail-method').className = `font-mono font-bold text-lg ${methodClass(r.method)}`;
document.getElementById('detail-path').textContent = r.path;
document.getElementById('detail-desc').textContent = r.description;
document.getElementById('detail-feature').textContent = r.feature || 'default';
document.getElementById('detail-auth').innerHTML = authBadge(r.auth);
const paramsDiv = document.getElementById('detail-params');
const tbody = document.getElementById('detail-params-body');
@ -147,12 +167,7 @@ function showDetail(index) {
} else {
paramsDiv.classList.add('hidden');
}
document.getElementById('route-detail').classList.remove('hidden');
}
function closeDetail() {
document.getElementById('route-detail').classList.add('hidden');
activeRoute = null;
document.getElementById('route-modal').showModal();
}
function filterRoutes() {
@ -170,4 +185,5 @@ function filterRoutes() {
renderRoutes(ROUTES);
</script>
{% endif %}
{% endblock content %}

View File

@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block title %}Backlog — Ontoref{% endblock title %}
{% block nav_backlog %}active{% endblock nav_backlog %}
{% block nav_group_track %}active{% endblock nav_group_track %}
{% block content %}
<div class="mb-5 flex items-center justify-between">

View File

@ -0,0 +1,197 @@
{% extends "base.html" %}
{% block title %}Config — {{ slug }} — Ontoref{% endblock title %}
{% block nav_config %}active{% endblock nav_config %}
{% block nav_group_dev %}active{% endblock nav_group_dev %}
{% block head %}
<style>
.status-ok { @apply badge badge-success badge-xs font-mono; }
.status-warning { @apply badge badge-warning badge-xs font-mono; }
.status-error { @apply badge badge-error badge-xs font-mono; }
.kind-ruststruct { @apply badge badge-ghost badge-xs font-mono text-orange-400; }
.kind-nuscript { @apply badge badge-ghost badge-xs font-mono text-cyan-400; }
.kind-cipipeline { @apply badge badge-ghost badge-xs font-mono text-purple-400; }
.kind-external { @apply badge badge-ghost badge-xs font-mono text-yellow-400; }
</style>
{% endblock head %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Config Surface</h1>
<p class="text-base-content/50 text-sm mt-1">
<span class="font-mono">{{ config_root }}</span>
<span class="mx-1">·</span>
<span class="font-mono">{{ entry_point }}</span>
<span class="mx-1">·</span>
<span class="badge badge-neutral badge-xs font-mono">{{ kind }}</span>
</p>
</div>
<span class="badge badge-lg {% if overall_status == 'Ok' %}badge-success{% elif overall_status == 'Warning' %}badge-warning{% else %}badge-error{% endif %}">
{{ overall_status }}
</span>
</div>
{% if not has_config_surface %}
<div class="flex flex-col items-center justify-center py-16 text-base-content/40 text-sm">
<p>No <code class="font-mono">config_surface</code> in <code class="font-mono">.ontology/manifest.ncl</code>.</p>
<p class="mt-2">Add a <code class="font-mono">config_surface</code> field to enable config management.</p>
</div>
{% else %}
{% for section in sections %}
<div class="card bg-base-200 rounded-lg mb-4">
<div class="card-body p-4">
<!-- Section header -->
<div class="flex items-start justify-between gap-2 mb-3">
<div class="flex-1">
<div class="flex items-center gap-2 flex-wrap">
<h2 class="font-bold font-mono text-lg">{{ section.id }}</h2>
{% if section.coherence %}
{% set coh = section.coherence %}
<span class="status-{{ coh.status | lower }}">{{ coh.status }}</span>
{% endif %}
{% if not section.mutable %}
<span class="badge badge-ghost badge-xs">read-only</span>
{% endif %}
</div>
{% if section.description %}
<p class="text-sm text-base-content/70 mt-0.5">{{ section.description }}</p>
{% endif %}
{% if section.rationale %}
<details class="mt-1">
<summary class="text-xs text-base-content/50 cursor-pointer hover:text-base-content/80">Why</summary>
<p class="text-xs text-base-content/60 mt-1 pl-2 border-l-2 border-base-300">{{ section.rationale }}</p>
</details>
{% endif %}
</div>
{% if section.mutable and current_role == "admin" %}
<button class="btn btn-xs btn-outline btn-primary"
onclick="openEditModal('{{ section.id }}', {{ section.current_values | json_encode() }})">
Edit
</button>
{% endif %}
</div>
<!-- Consumers -->
{% if section.consumers %}
<div class="flex flex-wrap gap-1.5 mb-3">
{% for c in section.consumers %}
<span class="kind-{{ c.kind | lower | replace(from='ruststruct', to='ruststruct') }}" title="{{ c.ref }}">
{{ c.kind | replace(from='RustStruct', to='Rust') | replace(from='NuScript', to='Nu') | replace(from='CiPipeline', to='CI') | replace(from='External', to='Ext') }}:{{ c.id }}
</span>
{% endfor %}
</div>
{% endif %}
<!-- Unclaimed fields warning -->
{% if section.coherence and section.coherence.unclaimed_fields %}
<div class="alert alert-warning py-2 px-3 text-xs mb-3">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span>Unclaimed fields: <span class="font-mono">{{ section.coherence.unclaimed_fields | join(sep=", ") }}</span></span>
</div>
{% endif %}
<!-- Current values -->
{% if section.current_values %}
<details class="text-xs">
<summary class="cursor-pointer text-base-content/50 hover:text-base-content/80 mb-1">
Current values
<span class="badge badge-ghost badge-xs ml-1">{{ section.file }}</span>
</summary>
<pre class="bg-base-300 p-3 rounded overflow-x-auto text-xs mt-2">{{ section.current_values | json_encode(pretty=true) }}</pre>
</details>
{% endif %}
<!-- Override history -->
{% if section.overrides and section.overrides | length > 0 %}
<details class="text-xs mt-2">
<summary class="cursor-pointer text-base-content/50 hover:text-base-content/80">
Override history <span class="badge badge-warning badge-xs ml-1">{{ section.overrides | length }}</span>
</summary>
<div class="mt-2 space-y-1">
{% for o in section.overrides %}
<div class="bg-base-300 p-2 rounded text-xs">
<span class="font-mono text-warning">{{ o.field }}</span>
<span class="text-base-content/50 mx-1">{{ o.from }} → {{ o.to }}</span>
{% if o.reason %}<span class="text-base-content/60">— {{ o.reason }}</span>{% endif %}
{% if o.ts %}<span class="text-base-content/40 ml-2">{{ o.ts }}</span>{% endif %}
</div>
{% endfor %}
</div>
</details>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Edit modal -->
<dialog id="edit-modal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg mb-4">Edit config section: <span id="edit-section-id" class="font-mono text-primary"></span></h3>
<div class="form-control mb-4">
<label class="label"><span class="label-text text-xs">Values (JSON)</span></label>
<textarea id="edit-values" class="textarea textarea-bordered font-mono text-sm h-48 resize-none" placeholder='{"key": "value"}'></textarea>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text text-xs">Reason for change</span></label>
<input type="text" id="edit-reason" class="input input-bordered input-sm" placeholder="e.g. port conflict with another service">
</div>
<div class="flex gap-2 items-center mb-4">
<input type="checkbox" id="edit-dry-run" class="checkbox checkbox-sm" checked>
<label for="edit-dry-run" class="text-sm">Dry run (preview only)</label>
</div>
<div id="edit-result" class="hidden">
<div class="divider text-xs">Preview</div>
<pre id="edit-result-body" class="bg-base-300 p-3 rounded text-xs overflow-x-auto max-h-48"></pre>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('edit-modal').close()">Cancel</button>
<button class="btn btn-outline btn-sm" onclick="submitConfig(true)">Preview</button>
<button class="btn btn-primary btn-sm" id="apply-btn" disabled onclick="submitConfig(false)">Apply</button>
</div>
</div>
</dialog>
<script>
let _editSection = '';
function openEditModal(id, values) {
_editSection = id;
document.getElementById('edit-section-id').textContent = id;
document.getElementById('edit-values').value = JSON.stringify(values, null, 2);
document.getElementById('edit-reason').value = '';
document.getElementById('edit-dry-run').checked = true;
document.getElementById('edit-result').classList.add('hidden');
document.getElementById('apply-btn').disabled = true;
document.getElementById('edit-modal').showModal();
}
async function submitConfig(dryRun) {
let values;
try { values = JSON.parse(document.getElementById('edit-values').value); }
catch(e) { alert('Invalid JSON: ' + e.message); return; }
const reason = document.getElementById('edit-reason').value;
const body = { values, dry_run: dryRun, reason };
const res = await fetch(`/api/projects/{{ slug }}/config/${_editSection}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
const resultEl = document.getElementById('edit-result');
const bodyEl = document.getElementById('edit-result-body');
bodyEl.textContent = JSON.stringify(data, null, 2);
resultEl.classList.remove('hidden');
if (dryRun) {
document.getElementById('apply-btn').disabled = false;
} else {
document.getElementById('apply-btn').disabled = true;
if (res.ok) { setTimeout(() => { document.getElementById('edit-modal').close(); window.location.reload(); }, 800); }
}
}
</script>
{% endif %}
{% endblock content %}

View File

@ -2,6 +2,7 @@
{% block title %}Ontology Graph — Ontoref{% endblock title %}
{% block nav_graph %}active{% endblock nav_graph %}
{% block nav_group_knowledge %}active{% endblock nav_group_knowledge %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>

View File

@ -3,6 +3,7 @@
{% block title %}Reflection Modes — Ontoref{% endblock title %}
{% block nav_modes %}active{% endblock nav_modes %}
{% block nav_group_reflect %}active{% endblock nav_group_reflect %}
{% block content %}
<div class="mb-4 flex items-center justify-between">

View File

@ -3,6 +3,7 @@
{% block title %}Notifications — Ontoref{% endblock title %}
{% block nav_notifications %}active{% endblock nav_notifications %}
{% block nav_group_track %}active{% endblock nav_group_track %}
{% block content %}
<div class="mb-6 flex items-center justify-between">

View File

@ -2,7 +2,7 @@
{% block title %}Q&A — Ontoref{% endblock title %}
{% block nav_qa %}active{% endblock nav_qa %}
{% block mob_nav_qa %}active{% endblock mob_nav_qa %}
{% block nav_group_track %}active{% endblock nav_group_track %}
{% block content %}
<!-- Hidden context for JS -->

View File

@ -2,6 +2,7 @@
{% block title %}Search — Ontoref{% endblock title %}
{% block nav_search %}active{% endblock nav_search %}
{% block nav_group_knowledge %}active{% endblock nav_group_knowledge %}
{% block content %}
<!-- Hidden context for JS -->

View File

@ -3,6 +3,7 @@
{% block title %}Sessions — Ontoref{% endblock title %}
{% block nav_sessions %}active{% endblock nav_sessions %}
{% block nav_group_reflect %}active{% endblock nav_group_reflect %}
{% block content %}
<div class="mb-6 flex items-center justify-between">

View File

@ -493,6 +493,178 @@ fn expand_ontology_node(ast: DeriveInput) -> syn::Result<proc_macro2::TokenStrea
})
}
/// Extract a `#[serde(rename = "...")]` value from a field's attributes.
/// Returns `None` if no serde rename is present.
fn serde_rename_of(field: &syn::Field) -> Option<String> {
use syn::punctuated::Punctuated;
use syn::MetaNameValue;
for attr in &field.attrs {
if !attr.path().is_ident("serde") {
continue;
}
let Ok(args) =
attr.parse_args_with(Punctuated::<MetaNameValue, Token![,]>::parse_terminated)
else {
continue;
};
let renamed = args
.iter()
.find(|kv| kv.path.is_ident("rename"))
.and_then(|kv| lit_str(&kv.value));
if renamed.is_some() {
return renamed;
}
}
None
}
// ── #[derive(ConfigFields)]
// ────────────────────────────────────────────────────────
/// Derive macro that extracts serde field names from a config struct and
/// registers them via `inventory::submit!` at link time.
///
/// Annotate the struct with `#[config_section(id = "...", ncl_file = "...")]`
/// to declare which NCL section this struct reads. The macro then emits an
/// `inventory::submit!(ConfigFieldsEntry { ... })` for each annotated struct,
/// allowing ontoref to compare declared Rust fields against NCL section exports
/// without running the daemon.
///
/// Respects `#[serde(rename = "...")]` — the registered field name is the
/// JSON key serde would deserialize, not the Rust identifier.
///
/// # Example
///
/// ```ignore
/// #[derive(serde::Deserialize, ConfigFields)]
/// #[config_section(id = "server", ncl_file = "config/server.ncl")]
/// pub struct ServerConfig {
/// pub host: String,
/// #[serde(rename = "listen_port")]
/// pub port: u16,
/// }
/// // Registers: section_id="server", fields=["host","listen_port"]
/// ```
#[proc_macro_derive(ConfigFields, attributes(config_section))]
pub fn derive_config_fields(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
match expand_config_fields(ast) {
Ok(ts) => ts.into(),
Err(err) => err.to_compile_error().into(),
}
}
fn expand_config_fields(ast: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
// Parse #[config_section(id = "...", ncl_file = "...")]
let mut section_id: Option<String> = None;
let mut ncl_file: Option<String> = None;
for attr in ast
.attrs
.iter()
.filter(|a| a.path().is_ident("config_section"))
{
let args =
attr.parse_args_with(Punctuated::<MetaNameValue, Token![,]>::parse_terminated)?;
for kv in &args {
let key = kv
.path
.get_ident()
.ok_or_else(|| syn::Error::new_spanned(&kv.path, "expected identifier"))?
.to_string();
let val = lit_str(&kv.value)
.ok_or_else(|| syn::Error::new_spanned(&kv.value, "expected string literal"))?;
match key.as_str() {
"id" => section_id = Some(val),
"ncl_file" => ncl_file = Some(val),
other => {
return Err(syn::Error::new_spanned(
&kv.path,
format!("unknown config_section key: {other}; expected id or ncl_file"),
))
}
}
}
}
let section_id = section_id.ok_or_else(|| {
syn::Error::new(
Span::call_site(),
"#[derive(ConfigFields)] requires #[config_section(id = \"...\", ncl_file = \"...\")]",
)
})?;
let ncl_file = ncl_file.ok_or_else(|| {
syn::Error::new(
Span::call_site(),
"#[derive(ConfigFields)] requires #[config_section(ncl_file = \"...\")]",
)
})?;
// Extract named fields, respecting #[serde(rename = "...")].
let fields = match &ast.data {
syn::Data::Struct(s) => match &s.fields {
syn::Fields::Named(named) => &named.named,
_ => {
return Err(syn::Error::new(
Span::call_site(),
"#[derive(ConfigFields)] requires a struct with named fields",
))
}
},
_ => {
return Err(syn::Error::new(
Span::call_site(),
"#[derive(ConfigFields)] can only be used on structs",
))
}
};
let field_names: Vec<String> = fields
.iter()
.map(|f| {
serde_rename_of(f)
.unwrap_or_else(|| f.ident.as_ref().map(|i| i.to_string()).unwrap_or_default())
})
.filter(|s| !s.is_empty())
.collect();
let field_lits: Vec<LitStr> = field_names
.iter()
.map(|s| LitStr::new(s, Span::call_site()))
.collect();
let section_lit = LitStr::new(&section_id, Span::call_site());
let ncl_file_lit = LitStr::new(&ncl_file, Span::call_site());
let type_name = &ast.ident;
let struct_name_lit = LitStr::new(&type_name.to_string(), Span::call_site());
let unique = {
let s = format!("{section_id}{ncl_file}");
s.bytes()
.fold(5381u64, |h, b| h.wrapping_mul(33).wrapping_add(b as u64))
};
let static_ident = syn::Ident::new(
&format!("__ONTOREF_CONFIG_FIELDS_{unique:x}"),
Span::call_site(),
);
Ok(quote! {
::inventory::submit! {
::ontoref_ontology::ConfigFieldsEntry {
section_id: #section_lit,
ncl_file: #ncl_file_lit,
struct_name: #struct_name_lit,
fields: &[#(#field_lits),*],
}
}
#[doc(hidden)]
#[allow(non_upper_case_globals, dead_code)]
static #static_ident: () = ();
})
}
// ── #[onto_validates]
// ─────────────────────────────────────────────────────────

View File

@ -34,3 +34,30 @@ pub struct TestCoverage {
}
inventory::collect!(TestCoverage);
/// A statically registered config field inventory entry.
///
/// Consumer projects derive `#[derive(ConfigFields)]` on their serde config
/// structs to emit `inventory::submit!(ConfigFieldsEntry { ... })` at link
/// time. Ontoref daemon and test helpers iterate these entries to compare
/// against NCL section exports — detecting unclaimed NCL fields and fields
/// expected by Rust but absent in the contract.
///
/// Field names respect `#[serde(rename = "...")]` — the registered name is
/// what serde would use to match the JSON key, not the Rust identifier.
pub struct ConfigFieldsEntry {
/// Matches the `id` in the project's `manifest.ncl
/// config_surface.sections`.
pub section_id: &'static str,
/// Path to the NCL file for this section, relative to the project root.
/// Used to locate the file for export during coherence verification.
pub ncl_file: &'static str,
/// Fully-qualified Rust type name (e.g.
/// `"vapora_backend::config::ServerConfig"`). Informational — used in
/// coherence reports for traceability.
pub struct_name: &'static str,
/// All serde field names this struct expects from the NCL JSON export.
pub fields: &'static [&'static str],
}
inventory::collect!(ConfigFieldsEntry);

View File

@ -6,13 +6,13 @@ pub mod types;
pub mod contrib;
#[cfg(feature = "derive")]
pub use contrib::{NodeContribution, TestCoverage};
pub use contrib::{ConfigFieldsEntry, NodeContribution, TestCoverage};
pub use error::OntologyError;
pub use ontology::{Core, Gate, Ontology, State};
// Re-export the proc-macro crate so consumers only need to depend on
// `ontoref-ontology` with the `derive` feature — no separate ontoref-derive dep.
#[cfg(feature = "derive")]
pub use ontoref_derive::{onto_validates, OntologyNode};
pub use ontoref_derive::{onto_validates, ConfigFields, OntologyNode};
pub use types::{
AbstractionLevel, CoreConfig, Coupling, Dimension, DimensionState, Duration, Edge, EdgeType,
GateConfig, Horizon, Membrane, Node, OpeningCondition, Permeability, Pole, Protocol,

View File

@ -12,7 +12,7 @@ def main [
--config: string = "", # path to config.ncl (default: ~/.config/ontoref/config.ncl)
] {
let platform = (sys host | get name)
let is_mac = ($platform | str starts-with "Mac")
let is_mac = ((^uname) == "Darwin")
let config_path = if ($config | is-empty) {
$"($env.HOME)/.config/ontoref/config.ncl"
@ -41,7 +41,7 @@ def main [
# ── 2. Nickel typecheck ────────────────────────────────────────────────────
let config_dir = ($config_path | path dirname)
let import_path = $"($config_dir):($data_dir)/schemas:($data_dir)"
let import_path = $"($config_dir):($config_dir)/schemas:($data_dir)/schemas:($data_dir)"
let check = (do {
with-env { NICKEL_IMPORT_PATH: $import_path } {
^nickel typecheck $config_path

View File

@ -31,4 +31,13 @@ let c = import "content.ncl" in
TemplateKind = c.TemplateKind,
make_asset = c.make_asset,
make_template = c.make_template,
# Config surface builders
ConfigKind = s.ConfigKind,
ConsumerKind = s.ConsumerKind,
ConfigConsumer = s.ConfigConsumer,
ConfigSection = s.ConfigSection,
ConfigSurface = s.ConfigSurface,
make_config_surface = fun data => s.ConfigSurface & data,
make_config_section = fun data => s.ConfigSection & data,
make_config_consumer = fun data => s.ConfigConsumer & data,
}

View File

@ -127,6 +127,79 @@ let justfile_convention_type = {
required_recipes | Array String | default = ["default", "help"],
} in
# ── Config surface ──────────────────────────────────────────────────────
# Describes the project's configuration system: where the NCL config lives,
# how it is structured, and which consumers (Rust structs, Nushell scripts,
# CI pipelines, external tools) read each section.
#
# A field in a section is "unclaimed" only if no consumer declares it —
# not merely absent from the Rust struct. CI/CD scripts and external tooling
# are first-class consumers.
#
# Mutation uses an override layer: original NCL files are never modified.
# Changes are written to {section}.overrides.ncl and merged via & at the
# entry point. Comments, contracts, and formatting in originals are preserved.
let config_kind_type = [|
'NclMerge, # multiple .ncl files merged via & operator
'TypeDialog, # .typedialog/ structure with form.toml + validators + fragments
'SingleFile, # single monolithic .ncl file
|] in
let consumer_kind_type = [|
'RustStruct, # serde::Deserialize struct in a Rust crate
'NuScript, # Nushell script accessing $config.field paths
'CiPipeline, # CI pipeline (Woodpecker, GitHub Actions, etc.)
'External, # external tool or process reading config JSON
|] in
let config_consumer_type = {
# Identifier for this consumer (e.g. "vapora-backend", "deploy-script").
id | String,
kind | consumer_kind_type,
# Reference path: Rust fully-qualified type or script path.
# e.g. "vapora_backend::config::ServerConfig" or "scripts/deploy.nu"
ref | String | default = "",
# Fields this consumer reads. Empty means the consumer reads all fields,
# which is treated as claiming all NCL keys for orphan analysis.
fields | Array String | default = [],
} in
let config_section_type = {
# Section identifier, must match the top-level NCL key (e.g. "server").
id | String,
# Path to the NCL file for this section, relative to config_root.
file | String,
# Path to the NCL contract file that types this section. Relative to
# contracts_path (or project root if contracts_path is empty).
contract | String | default = "",
description | String | default = "",
# Why this section exists and why the current values were chosen.
# Consumed by the quickref generator to explain decisions, not just values.
rationale | String | default = "",
# When false: ontoref will only read, never write, this section.
mutable | Bool | default = true,
# All consumers of this section. A NCL field present in no consumer is
# flagged as unclaimed in the coherence report.
consumers | Array config_consumer_type | default = [],
} in
let config_surface_type = {
# Directory containing config NCL files, relative to project root.
# e.g. "config/", "site/config/", ".typedialog/provisioning/"
config_root | String,
# Main NCL file that merges all sections (entry point for nickel export).
entry_point | String | default = "config.ncl",
kind | config_kind_type | default = 'NclMerge,
# Directory containing NCL contract files. Relative to project root.
# Passed as NICKEL_IMPORT_PATH component when exporting.
contracts_path | String | default = "",
# Directory where ontoref writes {section}.overrides.ncl files.
# Defaults to config_root when empty.
overrides_dir | String | default = "",
sections | Array config_section_type | default = [],
} in
# ── Claude baseline ─────────────────────────────────────────────────────
# Declares expected .claude/ structure per project.
@ -160,6 +233,10 @@ let manifest_type = {
# Reusable NCL templates for mode steps, agent prompts, and publication cards.
# Each template is a parameterised NCL function at source_path.
templates | Array content.ContentTemplate | default = [],
# Configuration surface: where the project's NCL config lives, which
# consumers read each section, and mutation rules. Optional — projects
# without a structured config system omit this field.
config_surface | config_surface_type | optional,
} in
{
@ -179,4 +256,9 @@ let manifest_type = {
JustfileConvention = justfile_convention_type,
ClaudeBaseline = claude_baseline_type,
ProjectManifest = manifest_type,
ConfigKind = config_kind_type,
ConsumerKind = consumer_kind_type,
ConfigConsumer = config_consumer_type,
ConfigSection = config_section_type,
ConfigSurface = config_surface_type,
}

View File

@ -69,8 +69,45 @@ def show-usage-brief [] {
print $"Use '($caller) help' for available commands\n"
}
def "main help" [group?: string] {
def "main help" [...args: string] {
# The bash wrapper rewrites `ore config show --help` → `main help config show`.
# When multiple tokens arrive, show the specific subcommand's Nushell help.
let group = ($args | first | default "")
if ($group | is-not-empty) {
if ($args | length) > 1 {
let subcmd = ($args | str join " ")
let cmd_name = $"main ($subcmd)"
let found = (scope commands | where name == $cmd_name | length) > 0
if $found {
let found_cmd = (scope commands | where name == $cmd_name | first)
let sig = ($found_cmd.signatures | values | first)
let pos = ($sig | where parameter_type == 'positional')
let named = ($sig | where parameter_type == 'named')
let pos_str = ($pos | get parameter_name | str join " ")
print $"Usage: ($cmd_name) [flags] ($pos_str)\n"
if ($found_cmd.description | is-not-empty) { print $found_cmd.description }
if ($found_cmd.extra_description | is-not-empty) { print $"\n($found_cmd.extra_description)" }
if ($named | length) > 0 {
print "\nFlags:"
for p in $named {
let flag = if ($p.short_flag | is-not-empty) {
$"-($p.short_flag), --($p.parameter_name)"
} else {
$" --($p.parameter_name)"
}
print $" ($flag) ($p.description)"
}
}
if ($pos | length) > 0 {
print "\nParameters:"
for p in $pos {
print $" ($p.parameter_name) <($p.syntax_shape)> ($p.description)"
}
}
return
}
# Unknown sub-path: fall through to group help.
}
help-group $group
return
}

View File

@ -0,0 +1,62 @@
let d = import "../defaults.ncl" in
d.make_mode String {
id = "config_coherence",
trigger = "Verify NCL↔consumer coherence for a project's config surface — detect unclaimed fields and consumer fields missing from the NCL export",
preconditions = [
"ONTOREF_PROJECT_ROOT is set and points to a project with config_surface in manifest.ncl",
"nickel is available in PATH",
"Nushell >= 0.110.0 is available",
"ontoref-daemon is running or ONTOREF_DAEMON_URL is set",
],
steps = [
{
id = "load-manifest",
action = "Export the project manifest and confirm config_surface is declared. Verify sections, consumers, and overrides_dir paths resolve.",
cmd = "nickel export --format json .ontology/manifest.ncl | from json | get config_surface",
actor = 'Both,
on_error = { strategy = 'Stop },
},
{
id = "export-configs",
action = "Export each NCL section file in parallel to get current field names and values. Uses NclCache if the daemon is running; falls back to direct nickel invocation.",
cmd = "open $env.ONTOREF_DAEMON_URL | url join --path $\"/api/projects/($slug)/config\"",
actor = 'Both,
depends_on = [{ step = "load-manifest" }],
on_error = { strategy = 'Continue },
},
{
id = "run-coherence",
action = "Run the multi-consumer coherence check via the daemon API. Returns per-section reports with unclaimed_fields and per-consumer missing_in_ncl.",
cmd = "http get $\"($env.ONTOREF_DAEMON_URL)/api/projects/($slug)/config/coherence\"",
actor = 'Both,
depends_on = [{ step = "export-configs" }],
on_error = { strategy = 'Stop },
},
{
id = "check-nu-accessors",
action = "For NuScript consumers that declare fields, verify the referenced .nu file actually contains $config.<section>.<field> access patterns. Flag mismatches.",
cmd = "rg --no-heading '\\$config\\.(\\w+)\\.(\\w+)' <nu-script-path>",
actor = 'Both,
depends_on = [{ step = "run-coherence" }],
on_error = { strategy = 'Continue },
},
{
id = "generate-quickref",
action = "Generate the living config documentation combining NCL values, manifest rationales, _meta_ records, and override history.",
cmd = "http get $\"($env.ONTOREF_DAEMON_URL)/api/projects/($slug)/config/quickref\"",
actor = 'Both,
depends_on = [{ step = "run-coherence" }],
on_error = { strategy = 'Continue },
},
{
id = "report",
action = "Summarise coherence findings: list unclaimed fields by section, consumer mismatches, and overall status (Ok | Warning | Error). Output as structured JSON for downstream tools.",
actor = 'Both,
depends_on = [{ step = "check-nu-accessors" }, { step = "generate-quickref" }],
on_error = { strategy = 'Stop },
},
],
}