Module System
Definitions
Section titled “Definitions”Capability
Section titled “Capability”The unit a module provides. A capability has a name, a signature, and a result. It is atomic or compound:
- Atomic — an indivisible operation. Single responsibility. Reads live state. Returns a result. Independently correct and independently testable.
- Compound — composed from other capabilities, its own or other modules’. The composition lives entirely in module code. No engine routes between steps; the module process calls each capability in turn and passes outputs forward.
Examples: nginx.reload, system.service.restart, nginx.domain.create (atomic); nginx.provision — creates the vhost, provisions SSL, reloads — (compound).
From the caller’s perspective there is no distinction. Both are declared as capabilities in the manifest, both are called by name, both have params and a result. Atomic or compound is an implementation fact, not a protocol fact.
Surface
Section titled “Surface”The module’s operator-facing view contract — what the module exposes for operators to navigate. Two required parts:
- Survey — ambient state view. The entry point. Mandatory.
- Focus — subject-aware detail view. Mandatory.
Survey and focus are always present together or not at all. A module may declare more than one surface, each scoped to a distinct subject type. The system module (core) itself declares surfaces — module (Inventory), job (Jobs), and message (Messages) — reached from the home page; the job surface drills into a job’s recorded exchanges. A read-only module shows reality and does not act on it.
A surface is declared in surface.json and rendered by a face — the REPL is the first face today; future faces (web, API) render the same surfaces without the module changing. Full surface contract in surface schema; the face role is defined in core architecture.
Action availability
Section titled “Action availability”Per-subject action availability is computed by the module’s focus handler, which returns the resolved set of actions for a given subject. Availability logic lives in module code — not in a manifest expression evaluated by core. Optional — absent for read-only modules, which show reality without acting on it.
Module Classification
Section titled “Module Classification”Classification follows from what a module implements. There is no type field. The manifest structure is the truth.
Util module — provides capabilities for other modules to use, with no surface. No survey, no focus, no operator-navigation presence. Its capabilities are consumed by other modules in any context — composition, survey reads, focus views.
Examples: suctl-mod-certbot, suctl-mod-systemd, suctl-mod-postgres
Surface module — declares one or more surfaces (survey + focus) plus whatever capabilities it needs. Appears in operator navigation when activated.
Examples: suctl-mod-nginx, suctl-mod-odoo, suctl-mod-fail2ban
A surface module that builds its surveys and focuses entirely by calling other modules’ capabilities — with no direct system reads of its own — is an aggregator. Structurally identical to any other surface module.
Example: suctl-mod-sites — unifies HTTP hosting (nginx, apache, caddy) and SSL (certbot, cloudflare)
under one operator interface. The operator manages sites; which web server or SSL provider is active
is the module’s concern, not the operator’s.
The modules survey shows each module’s actual composition: how many capabilities and surface actions it has. Type is derived from these numbers when displayed. It is never stored or declared.
Process Architecture
Section titled “Process Architecture”suctl core runs as a single process. Each module runs as an independent process.
Core communicates with each module over a private bidirectional socketpair it
creates at spawn and hands the child as SUCTL_BROKER_FD, using the protocol
defined in wire protocol. Core’s own capabilities (system.module.*,
system.version) are in-process — they are core, not a module.
The module protocol is the only contract between core and modules. Each module binary is compiled independently. Any language that can speak a Unix domain socket and exchange JSON envelopes can implement a module. suctl-mod-odoo is Python. Future modules may be any language.
Each active module is a child process with its own memory space. A misbehaving module cannot corrupt core or other modules. Memory and CPU scale with the number of active modules — operators activate only what they need.
Using, Not Inheriting
Section titled “Using, Not Inheriting”When a module uses another module’s capability, it calls it by name through core. The called module has no awareness of the caller. No inheritance chain. No override.
Chains are normal. webapps.provision calls nginx.provision which calls certbot.ssl.provision. Three separate processes, three invocations, each correct independently. Each link names the next exactly.
Data flows explicitly. A compound capability’s composition lives in module code: the module calls each capability in turn and passes outputs forward as it sees fit. There is no manifest step list and no engine routing between steps. A later call does not re-read what an earlier one already produced.
Names are globally unique by construction. nginx.provision and apache.provision are different names. A caller names exactly what it calls.
Capability Naming
Section titled “Capability Naming”Format: {module}.{action}, {module}.{domain}.{action}, or up to {module}.{domain}.{subdomain}.{action}.
module— the short name, without thesuctl-mod-prefix.domain— the subject area, used when a module manages more than one. Omitted when the action belongs directly to the module.subdomain— a further narrowing, used only when an action must be scoped within a domain.action— the verb.
Two to four segments. Three is the common case. The fourth segment is permitted only when scope genuinely narrows — for example a domain-scoped action that also exists at module level.
nginx.reload ← two parts: module-level actionnginx.domain.list ← three parts: action targets the domain subjectnginx.domain.create ← three partsnginx.ssl.renew ← three parts: action targets the ssl subjectnginx.maintenance.on ← three parts: maintenance for the whole modulenginx.domain.maintenance.on ← four parts: maintenance scoped to a single domaincertbot.ssl.provision ← three partsodoo.module.install ← three partsodoo.db.create ← three partsos.service.restart ← three parts: suctl-mod-os capabilitysystem.module.activate ← three parts: core capability (not a module)network.dns.check ← three partsnetwork.ping ← two parts: module-level actionFive or more segments are not accepted without a rigid written justification and approval. Depth is the exception, not the reach.
Capabilities are public by default — callable by any module that declares them in requires.capabilities. Anything not declared as a capability does not exist from core’s perspective and cannot be routed to; there is no private field.
What a Module Is
Section titled “What a Module Is”A module is a directory containing manifest.json and an executable entrypoint. Core reads the manifest from disk before starting the module process. Full manifest schema in manifest schema.
The manifest covers: version, protocol, platform, entrypoint, requirements (binaries, paths, sockets, permissions, other capabilities, config keys), capabilities, and optionally hooks. Module identity is derived from the directory name, not declared in the manifest. Surface configuration lives in a separate surface.json file (surface schema).
Module locations
Section titled “Module locations”/usr/lib/suctl/modules/ shipped modules — installed with suctl/usr/local/lib/suctl/modules/ default third-party path[operator-declared paths] additional paths in /etc/suctl/suctl.confmodule_paths: - /opt/custom/suctl/modulesThe same module name appearing in two paths is a name conflict. Core marks the module unavailable with a reason naming both paths, records a warning, and does not index the duplicate. The operator resolves by removing one of the conflicting installations.
Module activation
Section titled “Module activation”Core discovers modules, checks requirements, and surfaces what is ready. Activation requires explicit operator action. Core capabilities (system.module.*) are always available — they are in-process and require no activation step. When the operator activates a module whose required capabilities are provided by other ready-but-not-active modules, core returns CONFIRMATION_REQUIRED listing every module that will enter the active set; activation proceeds only after the operator confirms the named closure. Activation lifecycle hooks (pre-activate, post-activate, on-activate-fail, …) are described in module lifecycle.
core at startup:
phase 1 — discovery: scan module_paths for directories containing manifest.json load all manifests from disk into the module index
phase 2 — missing check: for each previously activated module not found in phase 1: mark missing — capabilities absent, operator notified
phase 3a — evaluate requirements (two-phase, cascade-aware): pass 1: for each discovered module, check system requirements (binaries, paths, sockets) against the live system — any miss marks the module unavailable pass 2: build the ready capability surface from modules that survived pass 1; for each remaining module check requires.capabilities against that surface (cascading: if certbot is unavailable because its binary is missing, nginx requiring certbot.cert.provision is also marked unavailable with a reason naming the provider and its state)
phase 3b — for each discovered module: not previously activated → mark ready, do not start previously activated: core-managed modules: create private bidirectional socketpair; pass child end via SUCTL_BROKER_FD launch process using entrypoint declared in manifest.json wait for handshake; validate live manifest protocol matches manifest.json timeout or manifest mismatch → mark unavailable, record reason success → activate, promote capabilities from ready to activeSurface modules that are ready but not activated are visible and activatable from the core Inventory view (provided by the system module). Util modules have no surface — their activation status is visible through the Inventory.
Activation state lives in /var/lib/suctl/modules/ — written by suctl, read by the operator through suctl.
Module states
Section titled “Module states”| State | Meaning | Reason recorded |
|---|---|---|
ready | Requirements met. Not yet activated by operator. | No |
active | Requirements met, activated, process running, capabilities registered. | No |
unavailable | Cannot be activated. Reason always present. | Yes — always |
missing | Was activated. Directory or manifest.json no longer on disk. | Yes |
Common unavailable reasons:
"required binary not found: nginx""required path not found: /etc/nginx/sites-available""required config key not set: cloudflare.api_token""module process not running""manifest mismatch"A module that is installed but whose process failed to start or timed out during handshake is
unavailable with reason "module process not running". It is not a distinct state.
Module configuration
Section titled “Module configuration”Each module owns one file in /etc/suctl/conf.d/. Core reads the directory live. Each file is the truth for that module’s configuration — operator-written, independently auditable, independently removable.
# suctl-mod-cloudflare.conf
# Required: Cloudflare API token with Zone.DNS read and edit permissions# cloudflare.api_token =
# Optional: request timeout in seconds (default: 10)cloudflare.timeout = 10The Capability Manifest
Section titled “The Capability Manifest”manifest.json on disk is the complete contract between the module and core: what the module provides, what it needs, and how it presents itself to the operator. Core reads it at startup before starting any module process. The live manifest returned at handshake must declare the same protocol version. Full field definitions in manifest schema.
Name Conflicts
Section titled “Name Conflicts”The same module name appearing in two paths is a conflict. Core marks the module unavailable with a reason naming both paths, records a warning, and does not index the duplicate — startup continues for every other module. The operator resolves by removing the conflicting installation.
Capability name conflicts are structurally impossible. Every capability name includes the module prefix. Two different modules cannot produce the same capability name.
There is no override mechanism. A module that wants different behavior declares its own capability with its own name.
Capability Dependencies
Section titled “Capability Dependencies”A compound capability calls other capabilities. The module declares every cross-module capability it needs in requires.capabilities. Core checks the live capability surface at execution time. A capability is available when everything it requires is active; when any requirement is missing, the capability is absent — with the missing capability named.
The Network Module
Section titled “The Network Module”The network module reads what the outside world sees about a domain or IP. DNS, HTTP, ping, TLS — all executed locally, measuring external visibility. The measurement is local. The subject is external.
Network capabilities are always async. Survey fields show checking... until resolved. Timeouts show unreachable — never a cached value.
The network module has its own survey. It does not inject into nginx’s survey. Two honest views of the same subject from different knowledge domains, navigated independently.
Module Lifecycle Hooks
Section titled “Module Lifecycle Hooks”Modules may declare hooks — callables that suctl invokes at specific orchestration events. A hook is how a module expresses what preparation, cleanup, or reconciliation it needs at each lifecycle transition. suctl knows when to call; the module knows what to do.
Hooks are declared in the manifest under the hooks key. A module with no hooks key behaves exactly as before — all hooks silently absent. Full hook taxonomy, execution contract, and patterns in module lifecycle.
Protocol Versioning
Section titled “Protocol Versioning”Core speaks protocol v1. Modules declare protocol v1 compatibility. Protocol changes are significant events requiring a decision entry. See wire protocol.