RFD 0005: Component lifecycle interface as v2 plugin loader¶
Summary¶
v2 introduces a plugin system that ships everything tool-specific outside the framework binary — language guides, framework guides, methodology workflows, agent translators, methodology hooks. This RFD adopts v1's Component interface (Detect / Install / Check / Uninstall) as the universal contract for all installable units, renames it to Plugin, and extends it with a Manifest() method plus three plugin kinds (skill, WASM, OCI) that all satisfy the same interface.
This is the foundational design RFD — every other v2 RFD depends on it. The framework's own bundled skills, every user-installable plugin, and every hook-extending plugin go through this interface. The orchestrator that runs Install in declared order with rollback-on-failure is the v2 plugin loader.
Problem statement¶
v2's "framework + skills hub" positioning ([[../../wiki/synthesis/positioning-rails-for-coding-assistants]]) requires a plugin system. v1 has none — its skill catalog is a static slice of structs in internal/core/registry.go ([[../../wiki/entities/registry]]). Adding a skill in v1 means editing Go code and rebuilding the binary, which kills the hub story.
v2 needs:
- A uniform installable-unit contract. Skills, WASM plugins, OCI plugins, methodology hooks, and the framework's own built-ins must all be manipulable through one interface so the framework code stays small.
- Atomic install with rollback. A failed install must leave the system in its prior state. Plugins might write files, create symlinks, run shell commands (composed externals), pull OCI images — every mutation needs reversal.
- Idempotent operations. Reinstalling a current plugin is a no-op. Uninstalling an absent plugin is a no-op. Critical for
samuel doctor --fixand for CI scripts. - Read-only health checking.
samuel doctormust traverse every installed plugin without mutating state — and without acquiring the install lock, so concurrent doctor calls are safe. - Structured error UX. Plugin failures must surface
Problem / Cause / Fix / DocsURLper [[../../wiki/concepts/structured-errors]], not raw Go errors. - Cross-process exclusion. Two concurrent
samuel installinvocations must serialize, not race on the filesystem.
Requirements¶
- Interface satisfied by all three plugin tiers ([[../../wiki/concepts/plugin-format]]): text skills, WASM plugins, OCI plugins.
- Interface satisfied by built-in framework components (the framework syncing its own bundled skills into
~/.samuel/builtins/). - Atomic stage-then-swap pattern for any filesystem mutation.
- Mutation log persisted to
samuel.locksoUninstallcan run later. - flock(2)-based cross-process lock at
~/.samuel/lock. DryRunmode that performs zero state mutation (including no lock acquisition).errors.Is/errors.Aswork across the structured-error boundary.
Constraints¶
- v2 is a clean break from v1; we are free to redesign. But v1's
Componentis the highest-quality subsystem in v1 ([[../../wiki/sources/2026-05-12-v1-orchestrator]]) and has 18 months of hardening. Discarding it without reason is wasteful. - Go 1.24+.
- Must not require external runtime (Docker/Podman) for text-skill installs — the loader is part of the framework binary.
- Plugin authors include third-party developers — the contract must be small, documented, and testable against fakes.
Background¶
v1's Component interface¶
[[../../wiki/entities/orchestrator]] documents v1's design. Five methods on every component:
type Component interface {
Name() string
Detect(ctx) (DetectResult, error)
Install(ctx, InstallOptions) (InstallResult, error)
Check(ctx) HealthStatus
Uninstall(ctx, UninstallOptions) (UninstallResult, error)
}
Companion types: DetectResult{Installed, Version, Path}, InstallResult{Component, Mutations, AlreadyInstalled, Skipped}, HealthStatus{Component, OK, Message, FixHint}, Mutation{Kind, Path, Description, Reverse}.
v1 ships three concrete components: GstackComponent (clones external repo), GbrainComponent (registers an MCP server), SamuelComponent (syncs embedded skills). The Orchestrator wires them in declared order. Both gstack and gbrain are dropped in v2 ([[../../wiki/entities/component-gstack-gbrain]]); SamuelComponent carries forward.
Industry patterns surveyed¶
Hashicorp go-plugin (github.com/hashicorp/go-plugin): RPC-over-Unix-socket plugin protocol used by Terraform, Vault, Consul. Mature, well-supported. Each plugin runs as a separate process. Suitable for runtime extension but conflates lifecycle (install/uninstall) with execution. Heavy for text-only plugins.
Go's plugin package: build-time .so loading. Famously fragile (matching toolchain versions, single-OS) and doesn't sandbox. Rejected by the community for production use.
npm / pip / cargo: package managers with explicit install/uninstall but no Detect or Check analogs. They assume external state lives in a single place (node_modules, site-packages, target). Doesn't generalize to "this plugin registered an MCP server" or "this plugin installed shell hooks."
Kubernetes operators: a reconcile loop with Reconcile(desired, observed) instead of explicit Install/Uninstall. Powerful for distributed systems but overkill for a single-machine CLI.
Cargo, Rustup, Homebrew: each implements its own variant of detect-then-install. Cargo's behavior maps closely to our needs: cargo install foo is idempotent, cargo uninstall foo reverses, cargo install --list enumerates. But Cargo's contract is internal — there's no first-class extension interface.
v1's empirical lessons (from [[../../wiki/sources/2026-05-12-v1-orchestrator]])¶
- The
Mutation { Reverse }log is the cleanest abstraction for atomic install — observed reliable rollback in production. - Rollback running on a fresh context (not the parent install context) prevents Ctrl-C from killing cleanup.
- Best-effort uninstall (errors joined, not aborted) is the right UX — "most things uninstalled, here's the failures" beats "stuck halfway."
DryRunmust skip lock acquisition; creating the lock file counts as state mutation.O_CLOEXECon the lock fd matters — child processes (shelled-out container runtimes, plugin executables) must not inherit and hold the lock past samuel's exit.- Lock file never deleted; deletion races with new acquirers taking flock on a fresh inode at the same path.
- Structured
*Errorwrapping (Problem/Cause/Fix/DocsURL) materially improved error UX in user-facing CLI output.
These are the kinds of details that get lost when an interface is redesigned from scratch.
Options considered¶
Option A: Port v1's Component verbatim, rename to Plugin, add Manifest() (chosen)¶
Keep the five-method interface. Rename for v2's "Rails for coding assistants" framing where "component" reads as too internal. Add one method:
type Plugin interface {
Name() string
Manifest() Manifest // NEW in v2
Detect(ctx) (DetectResult, error)
Install(ctx, InstallOptions) (InstallResult, error)
Check(ctx) HealthStatus
Uninstall(ctx, UninstallOptions) (UninstallResult, error)
}
Manifest() returns the parsed samuel-plugin.toml for capability checks, version range resolution, and dependency graph construction (see RFD 0001 for the manifest schema).
Three plugin kinds implement this interface via embedded structs:
type SkillPlugin struct { /* Git-fetch + extract */ }
type WasmPlugin struct { /* wazero-loaded module */ }
type OciPlugin struct { /* container-runtime-managed image */ }
func (p *SkillPlugin) Install(...) { ... }
func (p *WasmPlugin) Install(...) { ... }
func (p *OciPlugin) Install(...) { ... }
Built-in framework components (the embedded-skills syncer that replaces v1's SamuelComponent) also satisfy Plugin. The framework's plugin loader and its own self-install path share one code path.
Pros: - 18 months of v1 hardening preserved (atomic swap, mutation log, rollback context separation, lock semantics, structured errors). - One interface across all three plugin tiers + built-ins. The orchestrator code is reused unchanged. - Plugin authors learn one contract. - Generalizes naturally — adding a fourth plugin kind (gRPC plugin? subprocess plugin?) means a new struct implementing the same five methods. - errors.Is / errors.As semantics already proven in v1.
Cons: - "Component" → "Plugin" rename touches every reference (mostly mechanical). - Some types carry v1-era naming (InstallOptions.SkipGstack / SkipGbrain — drop those, they're gone in v2). - Five methods is mildly verbose for the simplest plugins (a pure-text skill needs only Install and Uninstall to do real work).
Effort: Low — most v1 code ports nearly verbatim. ~1 week of work in PRD 0002.
Option B: Roll a new interface from scratch¶
Design a "v2-native" interface unconstrained by v1. Examples a fresh designer might pick:
type Plugin interface {
Apply(ctx, state State) error // declarative — converge to desired state
State(ctx) (State, error) // current state
Validate(ctx) error // health
}
Move toward a Kubernetes-operator-style reconcile model.
Pros: - Cleaner conceptually. Apply subsumes Install + Uninstall (Apply with empty desired state = uninstall). - Smaller surface (3 methods vs 5). - Reconcile-pattern is well-studied and resilient to interrupted operations.
Cons: - Discards 18 months of v1 hardening. Every quirk re-encountered (the replace_existing_artifacts: true lesson, the O_CLOEXEC lesson, the rollback context separation lesson). - Reconcile-pattern overkill for single-machine CLI. We don't have eventual consistency, distributed observers, or operator-driven reconciliation. - Apply's "desired state" abstraction is fuzzier than v1's explicit Install for plugin authors writing simple skill plugins. - Mutation log idea has to be re-invented or dropped.
Effort: Medium-high — 2-3 weeks design + implementation, plus risk of re-discovering known-bad patterns.
Option C: Use Hashicorp go-plugin as the substrate¶
Adopt the hashicorp/go-plugin framework. Each plugin runs as a subprocess; communicate via gRPC or net/rpc. Define a PluginService gRPC contract.
Pros: - Battle-tested in Terraform / Vault. - Plugin process isolation by default. - gRPC contract is language-agnostic — plugin authors can write in any language. - Sandboxing comes free (separate process).
Cons: - Misaligned with v2's three-tier model. WASM plugins are best served via wazero (in-process), not a separate process. Text skills don't execute at all. OCI plugins already run in their own container. go-plugin's one-process-per-plugin model adds a process for every install/uninstall cycle, which is wasteful. - gRPC overhead for what is, in most cases, file copying. - Removes the framework's tight control over file mutations and rollback. - Doesn't model Detect / Check cleanly (those are pure functions over local state, not RPC calls). - Adds a heavy dependency.
Effort: High — full redesign around go-plugin's execution model. Worse fit for the three tiers we already decided on (RFD 0001).
Option D: Function-only interface (no lifecycle)¶
Reduce to:
Pass an operation enum; the plugin handles all three internally.
Pros: - Smallest possible interface. - Caller code is uniform.
Cons: - Loses the static type safety of distinct Install / Uninstall / Check signatures (different return types, different option structs). - Read-only semantics for Check are no longer enforceable at the type level — easy to leak mutations into health checks. - Mutation log doesn't fit naturally. - Discards the empirical lessons from v1.
Effort: Low — but for less value.
Option E: Separate interfaces for lifecycle vs runtime¶
Split into two:
type Lifecycle interface {
Install(ctx, opts) (Result, error)
Uninstall(ctx, opts) (Result, error)
Detect(ctx) (DetectResult, error)
Check(ctx) HealthStatus
Name() string
Manifest() Manifest
}
type Runtime interface {
Invoke(ctx, args) (Output, error)
}
type Plugin interface {
Lifecycle
Runtime // optional — text skills don't have one
}
Pros: - Distinguishes the install-time contract from the runtime contract. - Text skills implement only Lifecycle; WASM and OCI plugins implement both. - Runtime is where invocation happens (calling the plugin); Lifecycle is where Samuel manages its presence.
Cons: - Adds a second interface to learn. - v2.0 doesn't have a clear runtime-invocation model yet (the methodology hooks RFD will define it). Splitting now is premature abstraction. - Most of v1's value is in lifecycle; the runtime call can be added later as an additional interface without renaming or restructuring.
Effort: Low — but introduces an abstraction we don't yet need.
Decision¶
Adopt Option A: port v1's Component interface verbatim, rename to Plugin, add Manifest() method, drop v1-era options that no longer apply (gstack/gbrain skip flags).
The decision rests on three judgments:
-
v1's design is empirically vindicated. The orchestrator subsystem is the highest-quality piece of v1. Discarding it requires a positive reason — none of Options B-E offer enough value to overcome the loss of 18 months of hardening.
-
The three plugin tiers share lifecycle, not runtime. Detect/Install/Check/Uninstall is the same shape across text skills, WASM plugins, and OCI plugins. The runtime model (how a plugin is invoked at use time) differs per tier and gets layered on later (RFD 0004 for methodology hooks). v1's interface captures the common shape correctly.
-
Plugin author UX wins by simplicity. Five methods on one interface is teachable in a paragraph. Plugin authors writing a third-party plugin read one contract and ship.
Implementation plan¶
Phase 1 — port utilities (PRD 0001, week 1-2)¶
-
Port
*Errortype fromsamuel_v1/internal/orchestrator/errors.gotointernal/errors/. Same six fields, sameWrap/IsRecoverablehelpers. Update error code namespace fromSAM-toSAM-(unchanged) but inventory the codes now in use and reserve a numeric range per subsystem. -
Port flock(2) advisory lock from
samuel_v1/internal/orchestrator/lock_unix.goandlock_other.gotointernal/lock/. New lock path:~/.samuel/lock(was~/.claude/.samuel.lock).
Phase 2 — define interfaces (PRD 0002, week 3)¶
- Define
Plugininterface atinternal/plugin/plugin.go:
package plugin
type Plugin interface {
Name() string
Manifest() Manifest
Detect(ctx context.Context) (DetectResult, error)
Install(ctx context.Context, opts InstallOptions) (InstallResult, error)
Check(ctx context.Context) HealthStatus
Uninstall(ctx context.Context, opts UninstallOptions) (UninstallResult, error)
}
type Manifest struct {
Name string
Version string
Kind Kind // KindSkill | KindWasm | KindOci
Samuel FrameworkCompat // version range we satisfy
Provides Provides // skills, commands, hooks, methodology
Requires map[string]string // plugin -> version range
Capabilities Capabilities
}
type Kind string
const (
KindSkill Kind = "skill"
KindWasm Kind = "wasm"
KindOci Kind = "oci"
)
-
Define companion types (
DetectResult,InstallOptions,InstallResult,HealthStatus,Mutation,MutationKind,UninstallOptions,UninstallResult). Port shapes from v1 verbatim. DropSkipGstack/SkipGbrainfromInstallOptions— those plugins are gone. -
Define the
MutationKindenum. Inherit v1's:file_written,symlink_created,dir_created,command_run,git_clone. Add for v2:wasm_loaded,oci_pulled,lock_entry_written.
Phase 3 — port orchestrator (PRD 0002, week 4-5)¶
- Port
Orchestratorfromsamuel_v1/internal/orchestrator/orchestrator.gotointernal/orchestrator/. Keep: Install(ctx, opts)in declared order with rollback-on-failure on a fresh context (rollbackTimeout = 30s).Uninstall(ctx, opts)in reverse order, best-effort,errors.Jointo collect failures.Doctor(ctx)runsCheckon every plugin, no lock acquired.- Lock skipped in DryRun mode.
-
Wrapped
*Error{Recoverable: false, DocsURL: "https://samuelpkg.github.io/samuel/docs/errors/SAM-ROLLBACK-001"}when rollback also fails. -
Update lock and docs URLs to point at the new GitHub Pages host (no
samuel.devper earlier decision).
Phase 4 — first concrete plugin (PRD 0002, week 5)¶
- Port
SamuelComponentfromsamuel_v1/internal/orchestrator/component_samuel.gotointernal/components/samuel/. Adapt for v2: - Sync target:
~/.samuel/builtins/(was~/.claude/skills/samuel/). - Drop the project symlink — v2 plugins go to
<project>/.samuel/plugins/<name>/not via symlink. - Keep content-hash idempotency (SHA-256 over path + bytes).
- Keep atomic-swap pattern (sibling tmp dir + rename + backup-restore).
-
Keep path-traversal defense (
filepath.IsLocal). -
Implement
SamuelComponentas the first concretePlugin. ItsManifest()returns a synthetic manifest declaringkind = "builtin"(a fourth kind reserved for framework self-bundles, never appears in user-installed plugins).
Phase 5 — three plugin tier loaders (PRD 0003)¶
-
Implement
SkillPluginatinternal/plugin/skill/.Installclones the plugin's Git repo at the resolved tag, verifies cosign signature on the archive, copies SKILL.md + scripts + references + assets to<project>/.samuel/plugins/<name>/. -
Implement
WasmPluginatinternal/plugin/wasm/. Uses wazero.Installfetches the WASM blob, verifies signature, stores in<project>/.samuel/plugins/<name>/plugin.wasm.Checkinstantiates the module and calls itshealth()export. -
Implement
OciPluginatinternal/plugin/oci/. Detects container runtime (Podman rootless → Docker).Installpulls the image, pins digest insamuel.lock.Checkruns<runtime> inspect <image>. -
All three plugin kinds implement
Plugin. The orchestrator code doesn't change — it operates on the interface.
Phase 6 — test harness (parallel)¶
-
Build a
FakePlugintest type atinternal/plugin/testutil/that satisfiesPluginwith configurable failure injection. Used to test:- Successful install of N plugins in declared order.
- Rollback when the 2nd of 3 installs fails.
- Rollback when both install and rollback fail (the
Recoverable: falsewrapper case). - DryRun produces zero mutations.
- Concurrent installs serialize via flock.
- Doctor doesn't acquire the lock.
-
Integration tests use real plugins against fake Git/Docker servers.
Acceptance criteria (for this RFD's implementation)¶
-
Plugininterface defined;SamuelComponent(the first builtin plugin) satisfies it. -
Orchestrator.Installruns plugins in declared order, rolls back on failure. -
Orchestrator.Uninstallruns in reverse, joins errors, completes when partial failures occur. -
Orchestrator.DoctorrunsCheckper plugin without acquiring the lock. - DryRun mode performs zero filesystem mutation.
- flock at
~/.samuel/lockserializes concurrent installs across processes. - Rollback runs on a fresh context with 30s timeout.
- When both install and rollback fail, returned error wraps
*Error{Recoverable: false}. -
errors.Is/errors.Astraverse the orchestrator error boundary correctly. - All three plugin tiers (skill, WASM, OCI) implement the same
Plugininterface. - FakePlugin tests cover the matrix of install / rollback / uninstall scenarios.
Compatibility and migration¶
v2 is a clean break from v1 ([[../../wiki/entities/samuel-v2]]). No backward compatibility for v1's Component interface — v1 code is preserved at the v1-final tag of the GitHub repo, but v2 has no migration path from v1 plugin authors (there are none).
For users: samuel init in v2 establishes the new .samuel/ directory layout. v1's .claude/ directory is detected and reported by samuel doctor with a one-time warning suggesting manual cleanup, but never touched. Users running v1 + v2 side by side use different paths and different lock files.
Resolved decisions (2026-05-12)¶
-
Plugin install location: per-project copies at
<project>/.samuel/plugins/<name>/+ global content-addressed cache at~/.samuel/cache/plugins/<name>@<version>/. Per-project path is whatsamuel lswalks; global cache dedups content via hardlink-or-copy. -
Versioned plugin namespaces: yes — multiple versions of the same plugin can coexist across projects via the global content-addressed cache. Per-project install records which version it references.
-
Plugin protocol versioning vs framework versioning: kept separate as designed. Manifest declares both via
[samuel] framework = "^2.0.0"andprotocol = "^1.0.0". v2.0 ships framework v2.0.0 + protocol v1.0.0. Additive protocol changes are MINOR bumps; breaking changes are MAJOR and require N-1 compatibility for one major cycle. -
Plugin protocol invocation: split by tier.
- WASM plugins: direct function calls via wazero exports. No protocol — in-process invocation.
-
OCI plugins: gRPC over Unix socket via the
/samuel-bridgemount. Protobuf schema atapi/proto/plugin/v1/. Per [[0001|RFD 0001]] resolved decision #3. -
Hot reload: deferred to post-v2.0. Skill plugins reload naturally; WASM needs wazero module eviction; OCI probably can't (sandbox already running). Detailed design later.
Outcome¶
To be filled in after v2.0 implementation. Expected outcomes:
- Plugin interface stable enough that adding a fourth kind (subprocess plugin? gRPC plugin?) is a localized change.
- Rollback-on-failure measurably reduces "stuck halfway" support reports compared to v1.
- Plugin authors writing third-party plugins land their first plugin in <1 day, citing the documented interface as the reason.
Related artifacts¶
- [[../../wiki/synthesis/orchestrator-as-plugin-loader]] — wiki synthesis this RFD ports
- [[../../wiki/entities/orchestrator]] — v1 reference implementation
- [[../../wiki/concepts/component-lifecycle]] — detailed pattern documentation
- [[../../wiki/concepts/structured-errors]] — error UX dependency
- [[../../wiki/concepts/plugin-format]] — RFD 0001 territory, defines the three kinds
- PRD 0001 (Foundation) — implements
*Errorand lock - PRD 0002 (Core) — implements
Plugininterface,Orchestrator,SamuelComponent - PRD 0003 (Plugin Loader) — implements the three plugin tier structs