Command Ownership and Plugin Structure
The Folder Test
Every feature in Ze passes two folder tests:
Copy test: copy a plugin folder into the project, run codegen (./le repository generate),
and the plugin's commands, YANG schema, and handlers are live. No manual wiring.
Delete test: delete a plugin folder, run codegen, and every one of its features disappears. Every other plugin and the core keep working. No dangling references, no broken builds.
These two tests are the load-bearing invariant of the architecture. Everything below exists to make them hold.
Three Directories, Three Roles
internal/
core/ # shared primitives, no subsystem knowledge
component/ # shared subsystem implementations and services
plugins/ # self-contained command-only and full-subsystem owners
core/ -- Infrastructure Primitives
Leaf packages that provide reusable services. They depend on nothing else
internal (or only on other core/ packages). No subsystem-specific knowledge.
Examples: slogutil (logging), crashlog (panic capture), env (environment
variables), family (address families), health (health registry), metrics
(prometheus), events (event bus), diagnostic (doctor codes), textbuf
(string building), paths (file locations).
component/ -- Shared Subsystem Implementations
Subsystem logic that multiple owners use can compose core/ and other
component/ packages. A component owns configuration YANG when its data model
belongs to the shared subsystem rather than one removable plugin.
Examples include bgp (BGP engine), config (config system), cli (SSH CLI
editor), iface (interface management), host (hardware detection),
firewall, doctor (readiness checks), web, and l2tp.
plugins/ -- Self-Contained Feature Owners
Each folder under internal/plugins/ owns a removable feature surface. Two
plugin shapes use this directory:
- A command-only plugin owns command YANG, RPC handlers, and CLI
registration. It delegates the operation to a
component/orcore/package. - A full-subsystem plugin also owns its protocol or service runtime, configuration, lifecycle, and state in the same folder.
Both shapes can own user commands. Removing either folder removes all surfaces that belong to that owner.
A full-subsystem plugin declares the commands it owns in one commandDecls()
function in its own package. Two readers call it and neither copies it: the
registry.Registration its init() builds, which a catalog generator reads
from the linked composition root, and the sdk.Registration its runner passes
to p.Run, which a running daemon reads over Stage 1. ./le plugin declarations check fails with the package and the command when the two
disagree.
Plugin Directory Layout
internal/plugins/<name>/
register.go # lifecycle or offline CLI registration, when needed
*.go # runtime, config, and state for a full-subsystem plugin
yang/
ze-<name>-*.yang # hand-written command or config definitions
embed.go # GENERATED: //go:embed vars
register.go # GENERATED: yang.RegisterModule() calls
cmd/
register.go # pluginserver.RegisterRPCs() in init()
handler.go # RPC handler functions
Command-only plugins omit subsystem runtime files. Full-subsystem plugins keep their runtime with the command and configuration surfaces that they own.
YANG as Data, Not Code
YANG files are declarative definitions, not Go code. They live in a yang/
subfolder (not schema/) to make this clear. The Go glue files in yang/
(embed.go, register.go) are generated by codegen from the .yang files
present in the directory. The only hand-written file is the .yang itself.
This means adding a YANG command schema to a plugin is:
- Write
ze-<name>-cmd.yangin the plugin'syang/folder - Run
./le repository generate - Done: the codegen produces
embed.go,register.go, and updatesall.go
How Codegen Enables the Folder Test
The codegen (internal/le/plugin/imports.Write) scans the directory tree for:
- Packages containing
yang.RegisterModulecalls -> adds toall.goschema imports - Packages containing
pluginserver.RegisterRPCscalls -> adds toall.goRPC imports yang/directories containing.yangfiles -> generatesembed.go+register.go
Because discovery is directory-based, copying a plugin folder in (or deleting it) and re-running codegen is all that's needed to wire (or unwire) the plugin.
YANG Container Merge
Owners declare their commands by re-stating the path from the verb root:
module ze-host-cmd {
namespace "urn:ze:host:cmd";
prefix hostcmd;
import ze-extensions { prefix ze; }
container show {
container host {
container cpu {
ze:command "ze-show:host-cpu";
}
}
}
}
The YANG loader unions same-named top-level containers across all registered
modules. The owner module needs no import or augment of the central verb
schema. Give each module a unique namespace and prefix.
Verb-Root Anchors
Multi-owner verbs (show, clear, monitor, delete, set, update) keep a
bare anchor in the central package internal/component/cmd/<verb>/. The anchor
declares only truly generic commands (cross-plugin aggregations, process-global
introspection). Each owner container-merges its subtree onto the verb root.
A central self-containment test bans every migrated token to prevent drift back.
What Stays Central
A command stays in the central verb package only when it has no single removable owner:
- Aggregates a cross-plugin registry (
show policy list,show metrics name) - Reads the generic core system (
show version,show health,show uptime) - Is process-global (
show system memory,show system goroutines)
Self-Containment Invariant
Two tests enforce the invariant per verb:
Central guard (e.g. cmd/show/yang/self_containment_test.go):
a banned-token map listing every owner-migrated command. If any banned token
appears in the central YANG, the test fails.
Owner presence (e.g. plugins/host/yang/self_containment_test.go):
asserts the owner's YANG declares its commands. If the owner is deleted, the
presence test vanishes with it (correct). If someone moves the command back to
central, the presence test fails (incorrect, caught).
When carving a new command out of a central schema:
- Add the banned token to the central guard test
- Add the presence assertion to the owner's YANG test
- Both halves must exist before the carve is complete
Handler Registration
Handlers register via pluginserver.RegisterRPCs() in an init() function
inside the plugin's cmd/ package:
func init() {
pluginserver.RegisterRPCs(
pluginserver.RPCRegistration{
WireMethod: "ze-show:host-cpu",
Handler: handleShowHostCPU,
},
)
}
The package must be blank-imported (directly or transitively) from
internal/component/plugin/all/all.go. The codegen scans for packages
containing pluginserver.RegisterRPCs or yang.RegisterModule calls and
generates the import list.
What the Removal Test Forbids
| Anti-pattern | Why it fails the removal test |
|---|---|
A plugin's command spelling in generic dispatch (internal/component/plugin/server) |
Deleting the plugin leaves dead BGP or iface knowledge in shared code |
A plugin's subtree in a central verb schema, such as show bgp. in. internal/component/cmd/show/yang/ze-cli-show-cmd.yang |
Deleting the plugin leaves a show bgp branch with no handler |
Plugin handlers registered from a central verb package (internal/component/cmd/show, internal/component/cmd/delete) |
Deleting the plugin leaves the central package referencing gone symbols |
| Help, usage or inventory strings that hardcode a plugin's commands in a generic package | Deleting the plugin leaves help advertising commands that no longer exist |
The CLI helper (cmd/ze/internal/cmdutil) special-casing a plugin's selectors |
Selector handling is generic, and per-plugin knowledge belongs to the owner |
What shared code may carry
Generic command plumbing carries selector scope, not command spelling. The
dispatcher extracts a typed selector value because a YANG ArgDef declares it,
and it contains no plugin grammar: not the word peer, bgp or bfd. The
classification rule is ownership before grammar.
Finding the Owner: follow the code, not the wire method
The ze-<ns>: prefix on a WireMethod is a label rather than an ownership
claim, and it is often a legacy misnomer. The owner is what the handler actually
calls.
| Command (WireMethod) | What the handler calls | Owner |
|---|---|---|
ze-show:ip-route, ze-show:neighbors, ze-show:kernel-routes |
iface.ListKernelRoutes, iface.ListNeighbors (kernel tables through the iface backend) |
internal/component/iface, not central show and not the BGP RIB |
ze-bgp:pool-stats |
bgp/plugins/rib/pool attribute-pool metrics |
The BGP RIB plugin |
ze-bgp:metrics-values, ze-bgp:metrics-list |
The generic core Prometheus registry (internal/core/metrics) |
Generic, stays central |
ze-bgp:subscribe, ze-bgp:unsubscribe |
The generic pluginserver subscription manager |
Generic, stays central |
ze-show:policy-list |
The cross-plugin filter-type registry (registry.FilterTypesMap) |
Generic, stays central |
A command is generic, and stays central, only when it has no single removable
owner: it aggregates a cross-plugin registry, reads a generic core system, or is
process-global (show warnings, show health, subscribe). Everything that
reads one plugin's or one component's state belongs to that owner, whatever the
ze-<ns>: label on its WireMethod says.
Carving a Command Into Its Owner
- Handler. Add
func init() { pluginserver.RegisterRPCs(...) }and the handler in the owner package. When the owner package is already blank-imported, because it has aregister.gothe generator'spluginDirsfinds or it sits inrpcDirs, the registration links with no generator and no manual-island change. The handler importspluginandpluginserverplus the owner's own API, so it creates no import cycle. - Schema, by container merge rather than
augment. Add<owner>/yang/ze-<x>-cmd.yang, a standalone module that re-declares the path from the root. The YANG loader unions same-named top-level containers across every registered module, so the owner module needs noimportoraugmentof the central schema and has no base-module coupling. Give it a uniquenamespaceandprefix,import ze-extensions, and add the embed var and theyang.RegisterModulecall. A new<owner>/yang/package whoseregister.goimportsconfig/yangis auto-discovered, so re-run the plugin import generator to refreshinternal/component/plugin/all/all.go. - Schema location. The command YANG lives in
<owner>/yang/, a sibling ofcliandcmd, and never under<owner>/cmd/yang/. - Both halves of the invariant. The owner
yang/gets a presence test asserting its command tokens are declared, and the central verb schema test bans the moved tokens.
Unowned verb roots
A verb whose subcommands belong to several owners, such as monitor bgp,
monitor vpn ipsec and monitor ping, does not declare its root container
inside any one plugin. Declaring it there means deleting that plugin deletes the
whole verb. The root lives in a central, plugin-free package
internal/component/cmd/<verb>: a doc.go that blank-imports its yang/
subpackage. Each owner container-merges only its own subtree onto that root, and
the central package holds no handlers.
The root anchor stays even when it declares zero commands. Once every subcommand
of a verb has carved out, the central verb schema is a bare container <verb>
with no ze:command leaf of its own. internal/component/cmd/clear is the
precedent: clear interface counters (iface), clear dns cache (resolve),
clear vpn ipsec sa (ike), clear l2tp ... (l2tp) and clear bgp rib ...
(bgp) are all owner-owned, so ze-cli-clear-cmd.yang declares only the bare
anchor. Owners attach to it two ways, and the second has a hard dependency on
it:
- Container merge. The owner declares its own
container <verb> { container <noun> ... }and the YANG loader unions same-named roots. iface, resolve and ike use this forclear. New carves use this shape, because it creates no base-module coupling. - Augment. The owner declares
augment "/<prefix>:<verb>"against the anchor module. l2tp and bgp use this forclear, throughaugment "/cliclearcmd:clear". An augment names its target module, so deleting the anchor breaks every augmenting owner's build. That is the concrete reason the bare anchor remains.
Dedicated feature modules
When one feature spreads across several verbs, a one-shot root command, a show
view, a monitor stream and a resolve variant, the feature gets its own
module internal/component/<feature> that owns every one of those commands,
rather than scattering them across the verb packages. When two such modules
would share low-level primitives, ping and traceroute both build ICMP echo
packets and resolve targets, those primitives are extracted to an
internal/core/<x> package such as internal/core/probe, so neither feature
module depends on the other or on a central verb package.
Registration Over Hardcoding: the CLI client too
The registration discipline covers the CLI client model, not only the daemon's
command and schema tree. The daemon registers streaming views generically with
pluginserver.RegisterMonitorProvider(MonitorProvider{Prefix, CreateFn}) and
RegisterStreamingHandler(prefix, handler), resolved by longest-prefix
matchesPrefix. The Bubble Tea client mirrors this with its own view registry.
The anti-pattern is each rich live view (dashboard, traceroute, ping, traffic)
adding its own field, factory, state and dispatch to the core cli.Model, wired
one at a time in cmd/ze/hub/session_factory.go and
internal/component/cli/client/main.go. Every new view then edits the core
struct in four or five places, which is the opposite of a core that discovers
features through a registry.
The shape in the tree is the client-side view registry in
internal/component/cli/view_registry.go: RegisterView(viewSpec{key, prefix, matches, start}), RegisteredViews(), and a longest-prefix resolveView
copied from matchesPrefix. Each view registers from its own
register_view_*.go init() and hangs its session state off the single
Model.activeView handle plus the generic Model.viewFactories store, with no
per-feature field. Consumers iterate cli.RegisteredViews() and inject each
factory by key through SetViewFactory rather than through typed setters. The
TestModelHasNoPerFeatureViewField reflection guard fails when a per-feature
field returns.
The Removal-Compliance Guards In The Tree
The first instance is TestShowSchemaHasNoBGPPluginCommands in
internal/component/cmd/show/yang/self_containment_test.go. It asserts the
central show verb schema declares no part of the show bgp ... subtree
(ze-rib-api:, ze-bgp:peer-, ze-show:bgp-decode, ze-show:bgp-encode),
because show bgp rib ... and show bgp peer ... are owned by
internal/component/bgp/plugins/cmd/{rib,peer}/yang, and the offline
show bgp decode and show bgp encode diagnostics are owned by
internal/component/bgp/cli/yang. The owner half is
TestBGPToolsSchemaOwnsDecodeEncode, which asserts the surface moved rather
than vanished.
Non-BGP owners share one general central guard,
TestShowSchemaHasNoMigratedOwnerCommands in the same file. Its banned-token
map grows by one entry per carved owner: flow export, RSVP-TE, LDP, policy
routes, static, VPN IPsec, VPP, the iface kernel reads. Each owner's yang/
package holds the matching presence test, such as
TestRSVPTECmdSchemaOwnsShowRSVPTE. For clear, the pair is
TestClearSchemaHasNoMigratedOwnerCommands and, for example,
TestResolveCmdSchemaOwnsClearDNSCache.
Summary: Where Things Go
| Artifact | Location | Hand-written? |
|---|---|---|
| Implementation library | Shared code: component/<name>/ or core/<name>/Full-subsystem plugin: plugins/<name>/ |
✓ |
| Config YANG (data model) | Shared subsystem: component/<name>/yang/Full-subsystem plugin: plugins/<name>/yang/ |
✓ |
| Command YANG (CLI tree) | plugins/<name>/yang/, never <owner>/cmd/yang/ |
✓ |
| YANG embed + register | plugins/<name>/yang/ |
Generated |
| RPC handlers | plugins/<name>/cmd/, or the owner package |
✓ |
| Offline CLI registration | plugins/<name>/register.go |
✓ |
| Help, usage and completion | Derived from the owner's registry and schema | Derived |
| Doctor check and its unit test | The owner package | ✓ |
| Blank imports | all.go |
Generated |