Ctrl K
ComunidadGuías…

docs/lens-cookbook.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Lens Cookbook — Adding a Domain to the Crux Substrate

> Status: living document. Worked example uses the Features lens shipped in May 2026.

## What is a lens?

The Crux daemon's `corecrux-memory` crate ships a **substrate** — generic `entities` + `edges` + `kind_registry` with HTTP routes under `/v1/entities/*`, `/v1/edges/*`, `/v1/kinds/*` and matching MCP tools (`entity_*`, `edge_*`, `kind_*`, `entity_history`).

A **lens** is a Rust crate that:

1. **Registers one or more entity kinds** with the substrate's `KindRegistry` (JSON-Schema + allowed edges + description).
2. **Owns specialised HTTP routes** for queries that are awkward on the generic substrate — typically aggregations (gap analysis, coverage reports, dependency trees).
3. **Ships MCP tools** for agent ergonomics — domain-namespaced (`feature_*`, `task_*`, `goal_*`) instead of the generic `entity_*`.
4. **Optionally provides migration tools** to import legacy data (PlanCrux Postgres → Crux substrate, for example).

Trivial registries (Repo, Person, Promise) don't need a lens — they live as plain substrate entities and the generic `entity_*` routes are enough. Analytics-heavy domains earn a lens.

## Worked example: Features lens

Three files in `crates/crux-lens-features/`:

```text
src/
  lib.rs        — Re-exports + module wiring
  kinds.rs      — capability + repo KindRegistrations (JSON-Schema, edges, description)
  analytics.rs  — Pure functions over Vec<serde_json::Value>: compute_gaps,
                  compute_promise_coverage, compute_coverage_report
```

Cargo.toml is minimal — only `serde`, `serde_json`, `chrono`, and `corecrux-memory`.

## Step-by-step: adding a new lens

### 1. Create the workspace member

```bash
mkdir -p crates/crux-lens-<NAME>/src
```

`Cargo.toml`:
```toml
[package]
name = "crux-lens-<NAME>"
version.workspace = true
edition.workspace = true
license.workspace = true

[dependencies]
serde = { version = "1.0.218", features = ["derive"] }
serde_json = "1.0.139"
corecrux-memory = { path = "../corecrux-memory" }

[lints]
workspace = true
```

Add the crate to the root `Cargo.toml` workspace members.

### 2. Define kinds (`src/kinds.rs`)

```rust
use corecrux_memory::{KindError, KindRegistration, KindRegistry};
use serde_json::json;

pub const MY_KIND: &str = "task";
pub const PARENT_EDGE: &str = "parent_of";

pub fn bootstrap_kinds(reg: &mut KindRegistry) -> Result<(), KindError> {
    if !reg.is_registered(MY_KIND) {
        reg.register(KindRegistration {
            kind: MY_KIND.into(),
            description: "PlanCrux Task (Crux lens).".into(),
            allowed_outgoing_edges: vec![PARENT_EDGE.into()],
            allowed_incoming_edges: vec![PARENT_EDGE.into()],
            json_schema: json!({
                "type": "object",
                "required": ["id", "title", "state"],
                "properties": {
                    "id":    {"type": "string"},
                    "title": {"type": "string"},
                    "state": {"type": "string", "enum": ["open","in_progress","blocked","done"]}
                }
            }),
        })?;
    }
    Ok(())
}
```

Rules of thumb for the schema:

- Keep `required` minimal — every required field is a write that can fail at the substrate boundary.
- Use `enum` for closed sets (states, severities, maturities). The substrate's shallow validator enforces them at write time.
- Optional fields can be any type; the substrate's validator tolerates `null` for typed optionals (M4 ergonomics fix).
- Edges are declared symmetrically: if `task` can have `parent_of` going out, you also declare it as `allowed_incoming_edges` (because the same edge kind is what another task receives).

### 3. Write the analytics (`src/analytics.rs`)

Pure functions over `&[serde_json::Value]`. Don't take an `EntityStore` parameter — the caller (HTTP or MCP layer) reads from the store and hands you the payloads. This keeps analytics unit-testable without spinning up a daemon.

```rust
pub fn compute_unblocked_count(tasks: &[Value]) -> usize {
    tasks.iter().filter(|t| t["state"] == "open" && unblocked(t)).count()
}
```

### 4. Wire kind bootstrap in `corecruxd/src/main.rs`

After `AppState` is constructed:

```rust
{
    let mut reg = state.kind_registry.write().await;
    if let Err(e) = crux_lens_<NAME>::bootstrap_kinds(&mut reg) {
        tracing::warn!(error=%e, "<NAME> lens bootstrap_kinds returned an error");
    }
}
```

Multiple lenses can bootstrap. Each calls `bootstrap_kinds` which short-circuits on already-registered kinds, so the order is irrelevant.

### 5. Add HTTP routes in `corecruxd/src/http/<NAME>.rs`

Pattern: read capabilities/tasks/whatever from `state.entity_store`, call into the pure analytics, return JSON.

```rust
pub(super) async fn analysis_unblocked(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:read", "admin:read"]) {
        return p.into_response();
    }
    let store = state.entity_store.read().await;
    let tasks: Vec<_> = store
        .list(&EntityQuery { kind: Some("task".into()), ..Default::default() })
        .into_iter()
        .map(|e| e.payload.clone())
        .collect();
    let count = crux_lens_<NAME>::compute_unblocked_count(&tasks);
    (StatusCode::OK, Json(json!({"unblocked": count}))).into_response()
}
```

Add `mod <NAME>;` and routes in `corecruxd/src/http/mod.rs`. Conventional path prefix: `/v1/<lens-name>/<resource>`.

### 6. Add MCP tools in `crux-mcp/src/tools/<NAME>.rs`

Same shape as the HTTP handler, but reads `ctx.entity_store` and returns the MCP `content` array. Add `pub mod <NAME>;` in `tools/mod.rs`, plus:

- One `ToolDefinition` per tool in `list_tools()` (don't forget `examples` in the input_schema — `all_schemas_have_examples` test enforces this).
- One dispatch arm per tool in `call_tool()`.
- One output-doc entry per tool in `tool_output_docs()` (`tool_output_docs_covers_all_tools` test enforces this).
- Bump `TOOL_COUNT` in `mod.rs` tests.

If `tool_output_docs()`'s `json!` macro hits the recursion limit, the file already has `#![recursion_limit = "256"]` from the M3 fix; bump higher if needed.

### 7. Migration / cutover

If the data lives outside Crux (a legacy Postgres, a YAML file, an external API), write a one-shot migration script. Pattern:

- Read source.
- For each record, `PUT /v1/entities/<kind>/<id>` with the validated payload.
- For each edge, `PUT /v1/edges`.

Then a parity script that compares Crux state to source, dedupes upstream id-collisions (last-write-wins), and exits non-zero on divergence. Cutover pattern: keep a dual-write bridge running until parity holds for one full poll cycle, then proxy reads to Crux, then retire the legacy path.

### 8. Tests

Three layers:

- **Lens unit tests** (`crates/crux-lens-<NAME>/src/`): exercise the pure analytics functions and the kind bootstrap.
- **MCP tool tests** (`crates/crux-mcp/src/tools/<NAME>.rs`): seed entities via `entity_upsert`, call your handler, assert the MCP `content` payload.
- **Live daemon integration test** (`crates/crux-integration-tests/tests/daemon.rs`): one end-to-end test that seeds via `/v1/entities/<kind>/<id>`, hits your `/v1/<lens>/<analysis>` endpoint, asserts the response.

The third is the strongest signal: it boots the real binary and exercises every wired path.

## Don't

- **Don't add a route that scans all entities for an aggregation that runs frequently.** Either keep aggregations bounded (`limit` query param) or register a secondary index. Substrate scans are fine for once-per-page analytics; they're not fine for hot paths.
- **Don't write directly to `entities.jsonl` from the lens.** Always go through `EntityStore::upsert` so the journal write and the in-memory state stay consistent. Even the lens's own audit hooks should call `entity_store.write().await.upsert(...)`.
- **Don't use the generic `entity_*` MCP tools as the public surface for your lens.** They're fine for plumbing, but namespace your domain operations (`task_*`, `goal_*`) so agents discover the right tool by domain.
- **Don't tightly couple the lens to a specific storage backend.** The lens reads from `EntityStore::list(...)`. If we later swap sled in (for example), the lens code is unaffected because the trait shape is stable.
- **Don't bypass the kind validator.** Even if your lens "knows" the payload shape, the substrate runs validation on every upsert. Trust it; don't replicate it in the lens.

## Compose with the substrate

The substrate gives you for free:

- **Receipts / audit trail** — `entity_history(kind, id)` returns the full version chain. The lens does not need a separate audit table; just call this.
- **Soft-delete** — `entity_delete` marks deleted; `entity_history` keeps the deleted version. The lens's "trash" view is a free-form `entity_list?include_deleted=true&kind=…`.
- **Cross-lens linking** — an edge between `(capability, X)` and `(task, Y)` is a `PUT /v1/edges` with `edge_kind="implements"`. Both lenses can read it. No coordination needed beyond agreeing on the edge kind name.

## When to extract aggregation into the lens vs. expose generic substrate

| Operation | Substrate is enough | Lens should own it |
|---|---|---|
| Get one entity | ✅ `entity_get` | — |
| List entities of a kind | ✅ `entity_list?kind=…` | — |
| Filter by payload field | Substrate (post-filter callers) | If the filter is hot, register a secondary index in the substrate. |
| Aggregation across many entities | — | Lens analytics function. |
| Dependency walking | — | Lens (see `get_dependency_tree` in features). |
| Domain-specific write (audit) | — | Lens — usually involves merging into existing payload + upserting. |
| Cross-domain join | — | Higher-level: a service that reads from multiple lenses. The substrate doesn't try to do joins. |

## Reference implementation

The Features lens is the canonical reference:

- Crate: [`crates/crux-lens-features/`](../crates/crux-lens-features/)
- HTTP routes: [`crates/corecruxd/src/http/features.rs`](../crates/corecruxd/src/http/features.rs)
- MCP tools: [`crates/crux-mcp/src/tools/features.rs`](../crates/crux-mcp/src/tools/features.rs)
- Integration test: search `features_lens_end_to_end` in [`crates/crux-integration-tests/tests/daemon.rs`](../crates/crux-integration-tests/tests/daemon.rs).

## Sibling pattern: agent config

For workflow guardrails (CLAUDE.md / AGENTS.md profile fragments) instead of domain data, see the [Agent Config Wizard](./agent-config-wizard.md). Same "substrate generic + per-domain opt-in" philosophy, different target: the wizard ships rules; lenses ship data.