Vapora/adrs/adr-014-capability-packages.ncl
Jesús Pérez 75e5ebd9a2
Some checks failed
Documentation Lint & Validation / Markdown Linting (push) Has been cancelled
Documentation Lint & Validation / Validate mdBook Configuration (push) Has been cancelled
Documentation Lint & Validation / Content & Structure Validation (push) Has been cancelled
mdBook Build & Deploy / Build mdBook (push) Has been cancelled
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
Documentation Lint & Validation / Lint & Validation Summary (push) Has been cancelled
mdBook Build & Deploy / Documentation Quality Check (push) Has been cancelled
mdBook Build & Deploy / Deploy to GitHub Pages (push) Has been cancelled
mdBook Build & Deploy / Notification (push) Has been cancelled
chore: ontology sync + 4 NCL ADRs + landing page update
on+re:
  - core.ncl: 5 new Practice nodes (notification-channels,
    vapora-capabilities, agent-hot-reload-stable-identity,
    merkle-audit-trail, notification-channels) + 5 new edges;
    knowledge-graph-execution-history updated with HNSW+BM25+RRF
  - state.ncl: production-readiness blocker/catalyst updated (hot-reload
    complete, BudgetManager/LLMRouter still require restart);
    ontoref-integration catalyst updated (vapora-ontology/reflection
    crates, api-catalog.json, nickel contracts)

  ADRs (NCL):
  - adr-013: KG hybrid search — HNSW+BM25+RRF, rejected in-process scan
  - adr-014: capability packages — AgentDefinition→vapora-shared,
    DashMap shard-before-await constraint
  - adr-015: Merkle audit trail — SHA-256 hash chain, rejected HMAC
  - adr-016: agent hot-reload — stable_id=role, learning_profiles survive
    drain, BudgetManager excluded from reload scope

  landing page:
  - 2 new feature boxes: VCS-Agnostic Worktree (jj/git), Ontology Protocol
  - KG box: 20→28 tests, HNSW+BM25+RRF description
  - Agents box: 71→82 tests, hot-reload + stable_id
  - tech stack: Rust 21→23 crates, added jj, Radicle, ontoref badges
  - status badge: 620→691 tests
2026-04-07 21:06:48 +01:00

78 lines
5.9 KiB
Text

let d = import "adr-defaults.ncl" in
d.make_adr {
id = "adr-014",
title = "Capability Packages — vapora-capabilities Crate with In-Process Executor Dispatch",
status = 'Accepted,
date = "2026-02-26",
context = "VAPORA agent roles (Developer, Reviewer, Architect) were enum variants with no attached system prompt or model preference. Every new deployment required manual agents.toml editing before agents produced domain-appropriate responses. AgentDefinition lived in vapora-agents::config; any capability crate that wanted to produce AgentDefinitions would have to import vapora-agents, and if vapora-agents also imported vapora-capabilities for built-in capability loading, a compile-time circular dependency would result. Additionally, AgentCoordinator dispatched tasks by serializing them to NATS JetStream and waiting for an external process — no path existed for in-process executor dispatch needed by capability built-ins.",
decision = "Introduce vapora-capabilities crate exposing Capability trait, CapabilitySpec, CapabilityRegistry, and CapabilityLoader. Relocate AgentDefinition from vapora-agents::config to vapora-shared to break the circular dependency. Add AgentExecutor::with_router(Arc<LLMRouter>) builder. Add AgentCoordinator in-process executor dispatch via DashMap<String, Sender<TaskAssignment>> — shard lock released before .await by cloning the Sender out of the map. Built-in implementations: CodeReviewer, DocGenerator, PRMonitor.",
rationale = [
{
claim = "AgentDefinition belongs in vapora-shared — it is a plain data-transfer type with no orchestration logic",
detail = "AgentDefinition contains role, provider, model, system_prompt — no async traits, no runtime state, no I/O. Its presence in vapora-agents::config was an artifact of where it was first needed, not where it conceptually belongs. Moving it to vapora-shared eliminates the circular dependency without changing any observable behavior. vapora-agents re-exports it for backward compatibility.",
},
{
claim = "In-process executor dispatch requires releasing the DashMap shard lock before .await",
detail = "DashMap shard entries hold a read/write guard. If the guard is held across an .await point, the executor that eventually processes the task cannot re-enter the same shard to update state — deadlock. The fix: clone the Sender<TaskAssignment> out of the map (releases the guard), then call sender.send(assignment).await. This is a Rust async correctness constraint, not a design preference.",
},
{
claim = "Capability bundles ship a system prompt, not a crate — operators activate by name, not by file",
detail = "Manual agents.toml prompt engineering was the alternative. It required locating the file, writing a semantically correct system prompt, choosing a model, and restarting the server. CapabilityLoader resolves built-ins by name ('code-reviewer') with TOML override support — zero-config activation, operator-override when needed.",
},
],
consequences = {
positive = [
"New agent type activation requires a single config entry; system prompt and model default ship with the capability",
"AgentDefinition circular dependency eliminated at the Cargo level — cargo check catches any regression immediately",
"In-process executor dispatch avoids NATS round-trip for capability-backed agents, reducing task latency",
"reload_agents uses CapabilityRegistry to re-spawn built-in executors after hot-reload",
],
negative = [
"AgentDefinition in vapora-shared means vapora-shared now has awareness of capability/agent concepts — previously it was pure data types",
"In-process dispatch bypasses NATS audit trail for capability tasks — task events are not published to JetStream for capability-dispatched work",
],
},
alternatives_considered = [
{
option = "Keep AgentDefinition in vapora-agents, use trait objects to break the cycle",
why_rejected = "Trait-object indirection to break a circular dependency adds abstraction without adding value. Moving the struct is simpler, traceable, and requires fewer files.",
},
{
option = "External NATS dispatch only — no in-process executor channel",
why_rejected = "NATS dispatch requires a running NATS server. Capability built-ins need to function in environments without NATS (local dev, test). In-process dispatch with NATS graceful fallback is the correct model.",
},
],
constraints = [
{
id = "agent-definition-in-shared",
claim = "AgentDefinition must live in vapora-shared, not vapora-agents::config — vapora-capabilities must not import vapora-agents",
scope = "crates/vapora-shared/src/",
severity = 'Hard,
check = { tag = 'Grep, pattern = "AgentDefinition", paths = ["crates/vapora-shared/src/"], must_be_empty = false },
rationale = "Moving AgentDefinition back to vapora-agents recreates the circular dependency that this ADR was written to prevent.",
},
{
id = "dashmap-shard-released-before-await",
claim = "DashMap shard guards must not be held across .await points in AgentCoordinator dispatch — clone the Sender before awaiting",
scope = "crates/vapora-agents/src/coordinator.rs",
severity = 'Hard,
check = { tag = 'Grep, pattern = "executor_channels.*get\\|sender.*clone", paths = ["crates/vapora-agents/src/coordinator.rs"], must_be_empty = false },
rationale = "Holding a DashMap guard across .await deadlocks re-entrant shard access from the receiving executor.",
},
],
related_adrs = ["adr-009", "adr-005"],
ontology_check = {
decision_string = "vapora-capabilities crate; AgentDefinition relocated to vapora-shared; in-process executor dispatch via DashMap<String, Sender>; shard lock released before .await",
invariants_at_risk = ["message-based-coordination"],
verdict = 'Safe,
},
}