provisioning/adrs/adr-028-daemon-target-registry-field.ncl

85 lines
8.7 KiB
XML

let d = import "adr-defaults.ncl" in
d.make_adr {
id = "adr-028",
title = "daemon_target: Registry Field for CLI Query Routing",
status = 'Accepted,
date = "2026-04-18",
context = "The commands-registry.ncl schema had `requires_daemon: Bool` — a binary flag indicating whether the orchestrator daemon must be running before the command can execute. This is a runtime precondition check, not a routing directive. As prvng-cli (ADR-027) introduces a second daemon (the registry query daemon) and the orchestrator remains a third service, the question of which system should serve a given CLI query becomes a distinct concern from whether that query's command needs the orchestrator running at execution time. A future fourth service (e.g., an AI assistant backend) might need to handle `prvng ai` queries. Without an explicit routing field in the registry, the bash wrapper must embed routing logic as ad-hoc case statements that drift out of sync with the registry. The registry is already the authoritative source of truth for command metadata; routing belongs there.",
decision = "Add `daemon_target` as an enum field to the CommandEntry schema with three values: `none` (command is handled locally by Nu thin handlers), `cli` (command's query should be routed to the prvng-cli Unix socket), `orchestrator` (command's query should be routed to the orchestrator service on port 9011). Default is `none`. The field is added to: (1) `schemas/commands_registry/schema.ncl` as `daemon_target | std.enum.TagOrString | [| 'none, 'cli, 'orchestrator |]`; (2) `schemas/commands_registry/defaults.ncl` as `daemon_target | default = 'none`; (3) `platform/crates/prvng-cli/src/registry.rs` as `DaemonTarget` enum with serde `rename_all = \"lowercase\"` and `Default = None`; (4) `LookupResult` in registry.rs as `daemon_target: Option<String>` serialized in every socket response. The bash wrapper reads `daemon_target` from the socket response but does not yet act on it — the field is present for forward compatibility, enabling future routing without a schema migration.",
rationale = [
{
claim = "Routing intent belongs in the registry, not in bash case statements",
detail = "The current bash wrapper routes commands via hard-coded case branches. Adding a new service requires editing the bash wrapper in two places: the dispatch block and the `_validate_command` daemon check. With `daemon_target` in the registry, routing is data: a new service is a new enum variant, and the bash wrapper reads the variant rather than containing the routing logic. This is the configuration-driven principle applied to service dispatch.",
},
{
claim = "`daemon_target` and `requires_daemon` are orthogonal — both must exist",
detail = "`requires_daemon: Bool` answers 'does this command need the orchestrator running at execution time?' — it is a precondition for command execution. `daemon_target` answers 'which service should handle the CLI query for this command?' — it is a routing directive. A command can have `requires_daemon = true` (needs orchestrator to execute) and `daemon_target = none` (CLI query is handled locally by Nu). A command could have `daemon_target = orchestrator` (the orchestrator itself handles the lookup) with `requires_daemon = false` (but this is currently unused). Conflating them would require complex boolean combinations to express future routing needs.",
},
{
claim = "Enum over TagOrString allows gradual adoption without schema breakage",
detail = "`std.enum.TagOrString` is Nickel's mechanism for enums that also accept plain strings. This means existing JSON consumers that read `daemon_target` as a string continue to work. Future enum variants (e.g., `'ai-service`) can be added to the schema without forcing all existing entries to be updated — they continue to deserialize as `none` via the Rust `Default` impl.",
},
{
claim = "Returning `daemon_target` in every LookupResult socket response costs ~20 bytes and adds zero latency",
detail = "The socket response is already a JSON object. Adding `\"daemon_target\":\"none\"` to the 40-command registry adds ~20 bytes per response. At Unix socket speeds (loopback, no copy), this is below measurement threshold. Omitting the field from responses would require schema versioning if it is added later; including it from the start avoids that migration.",
},
],
consequences = {
positive = [
"Future services can be added to the routing table by adding an enum variant — no bash wrapper changes required for the routing data layer",
"Registry is the single source of truth for both command metadata and routing intent",
"`LookupResult` carries routing information, enabling smart clients (IDE plugins, MCP tools) to route queries without duplicating registry logic",
],
negative = [
"The bash wrapper currently ignores `daemon_target` from the socket response — it reads `requires_daemon` only. Acting on `daemon_target` requires a future bash wrapper change that maps `daemon_target=orchestrator` to an HTTP/WebSocket call to port 9011 instead of local Nu execution.",
"Adding a new `daemon_target` variant requires: (1) schema.ncl update, (2) Rust enum update + rebuild, (3) re-export of commands-registry.json. The schema and Rust must stay in sync manually — there is no codegen.",
],
},
alternatives_considered = [
{
option = "Use `requires_daemon` as a proxy for routing — orchestrator-requiring commands route to orchestrator",
why_rejected = "The semantics differ. `requires_daemon = true` means the command cannot execute without the orchestrator — it does not mean the orchestrator should handle the CLI query. A future command might need the orchestrator for data but want its query interface served by prvng-cli (e.g., cached orchestrator state). Overloading `requires_daemon` would require a boolean override field for these cases, which is worse than having a dedicated routing field.",
},
{
option = "Encode routing in command naming convention (prefix: `orch:workspace`, `cli:help`)",
why_rejected = "Naming conventions require parser logic in every consumer and break when commands are renamed. A dedicated schema field is strongly typed, validated by `nickel typecheck`, and queryable by grep without special parsing.",
},
{
option = "Add routing to a separate registry file (routing-registry.ncl)",
why_rejected = "Two registries for the same command set creates synchronization debt: adding a command requires editing both files, and a mismatch is not detectable without running both through a diff tool. The registry is already the authoritative command list; routing is command metadata and belongs in the same record.",
},
],
constraints = [
{
id = "daemon-target-rust-enum-in-sync",
claim = "The DaemonTarget enum in platform/crates/prvng-cli/src/registry.rs must contain exactly the variants declared in schemas/commands_registry/schema.ncl: None, Cli, Orchestrator",
scope = "platform/crates/prvng-cli/src/registry.rs, provisioning/schemas/commands_registry/schema.ncl",
severity = 'Hard,
check = { tag = 'Manual, description = "grep -c 'None\\|Cli\\|Orchestrator' platform/crates/prvng-cli/src/registry.rs — must equal 3; grep TagOrString provisioning/schemas/commands_registry/schema.ncl — must find daemon_target line" },
rationale = "A variant in the schema with no Rust counterpart causes serde deserialization to fail at runtime on any registry entry using the new variant. Manual sync is required until codegen is available.",
},
{
id = "daemon-target-default-none",
claim = "All commands in commands-registry.ncl that do not explicitly set daemon_target must resolve to `none` via the schema default",
scope = "provisioning/schemas/commands_registry/defaults.ncl",
severity = 'Hard,
check = { tag = 'Grep, pattern = "daemon_target.*default.*none", paths = ["provisioning/schemas/commands_registry/defaults.ncl"], must_be_empty = false },
rationale = "The default ensures backward compatibility: existing registry entries without daemon_target are valid and route locally. A missing default would make the field required and break all existing make_command calls.",
},
],
ontology_check = {
decision_string = "Add daemon_target enum field (none|cli|orchestrator) to CommandEntry schema and LookupResult for forward-compatible CLI query routing without conflating with requires_daemon precondition",
invariants_at_risk = ["config-driven-always", "type-safety-always"],
verdict = 'Safe,
},
related_adrs = ["adr-027-prvng-cli-daemon"],
}