Skip to content

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:

  1. 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.
  2. 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.
  3. Idempotent operations. Reinstalling a current plugin is a no-op. Uninstalling an absent plugin is a no-op. Critical for samuel doctor --fix and for CI scripts.
  4. Read-only health checking. samuel doctor must traverse every installed plugin without mutating state — and without acquiring the install lock, so concurrent doctor calls are safe.
  5. Structured error UX. Plugin failures must surface Problem / Cause / Fix / DocsURL per [[../../wiki/concepts/structured-errors]], not raw Go errors.
  6. Cross-process exclusion. Two concurrent samuel install invocations 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.lock so Uninstall can run later.
  • flock(2)-based cross-process lock at ~/.samuel/lock.
  • DryRun mode that performs zero state mutation (including no lock acquisition).
  • errors.Is / errors.As work across the structured-error boundary.

Constraints

  • v2 is a clean break from v1; we are free to redesign. But v1's Component is 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."
  • DryRun must skip lock acquisition; creating the lock file counts as state mutation.
  • O_CLOEXEC on 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 *Error wrapping (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:

type Plugin interface {
    Apply(ctx, op Operation) error    // Install | Uninstall | Check
}

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:

  1. 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.

  2. 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.

  3. 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)

  1. Port *Error type from samuel_v1/internal/orchestrator/errors.go to internal/errors/. Same six fields, same Wrap / IsRecoverable helpers. Update error code namespace from SAM- to SAM- (unchanged) but inventory the codes now in use and reserve a numeric range per subsystem.

  2. Port flock(2) advisory lock from samuel_v1/internal/orchestrator/lock_unix.go and lock_other.go to internal/lock/. New lock path: ~/.samuel/lock (was ~/.claude/.samuel.lock).

Phase 2 — define interfaces (PRD 0002, week 3)

  1. Define Plugin interface at internal/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"
)
  1. Define companion types (DetectResult, InstallOptions, InstallResult, HealthStatus, Mutation, MutationKind, UninstallOptions, UninstallResult). Port shapes from v1 verbatim. Drop SkipGstack / SkipGbrain from InstallOptions — those plugins are gone.

  2. Define the MutationKind enum. 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)

  1. Port Orchestrator from samuel_v1/internal/orchestrator/orchestrator.go to internal/orchestrator/. Keep:
  2. Install(ctx, opts) in declared order with rollback-on-failure on a fresh context (rollbackTimeout = 30s).
  3. Uninstall(ctx, opts) in reverse order, best-effort, errors.Join to collect failures.
  4. Doctor(ctx) runs Check on every plugin, no lock acquired.
  5. Lock skipped in DryRun mode.
  6. Wrapped *Error{Recoverable: false, DocsURL: "https://samuelpkg.github.io/samuel/docs/errors/SAM-ROLLBACK-001"} when rollback also fails.

  7. Update lock and docs URLs to point at the new GitHub Pages host (no samuel.dev per earlier decision).

Phase 4 — first concrete plugin (PRD 0002, week 5)

  1. Port SamuelComponent from samuel_v1/internal/orchestrator/component_samuel.go to internal/components/samuel/. Adapt for v2:
  2. Sync target: ~/.samuel/builtins/ (was ~/.claude/skills/samuel/).
  3. Drop the project symlink — v2 plugins go to <project>/.samuel/plugins/<name>/ not via symlink.
  4. Keep content-hash idempotency (SHA-256 over path + bytes).
  5. Keep atomic-swap pattern (sibling tmp dir + rename + backup-restore).
  6. Keep path-traversal defense (filepath.IsLocal).

  7. Implement SamuelComponent as the first concrete Plugin. Its Manifest() returns a synthetic manifest declaring kind = "builtin" (a fourth kind reserved for framework self-bundles, never appears in user-installed plugins).

Phase 5 — three plugin tier loaders (PRD 0003)

  1. Implement SkillPlugin at internal/plugin/skill/. Install clones 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>/.

  2. Implement WasmPlugin at internal/plugin/wasm/. Uses wazero. Install fetches the WASM blob, verifies signature, stores in <project>/.samuel/plugins/<name>/plugin.wasm. Check instantiates the module and calls its health() export.

  3. Implement OciPlugin at internal/plugin/oci/. Detects container runtime (Podman rootless → Docker). Install pulls the image, pins digest in samuel.lock. Check runs <runtime> inspect <image>.

  4. All three plugin kinds implement Plugin. The orchestrator code doesn't change — it operates on the interface.

Phase 6 — test harness (parallel)

  1. Build a FakePlugin test type at internal/plugin/testutil/ that satisfies Plugin with 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: false wrapper case).
    • DryRun produces zero mutations.
    • Concurrent installs serialize via flock.
    • Doctor doesn't acquire the lock.
  2. Integration tests use real plugins against fake Git/Docker servers.

Acceptance criteria (for this RFD's implementation)

  • Plugin interface defined; SamuelComponent (the first builtin plugin) satisfies it.
  • Orchestrator.Install runs plugins in declared order, rolls back on failure.
  • Orchestrator.Uninstall runs in reverse, joins errors, completes when partial failures occur.
  • Orchestrator.Doctor runs Check per plugin without acquiring the lock.
  • DryRun mode performs zero filesystem mutation.
  • flock at ~/.samuel/lock serializes 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.As traverse the orchestrator error boundary correctly.
  • All three plugin tiers (skill, WASM, OCI) implement the same Plugin interface.
  • 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)

  1. 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 what samuel ls walks; global cache dedups content via hardlink-or-copy.

  2. 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.

  3. Plugin protocol versioning vs framework versioning: kept separate as designed. Manifest declares both via [samuel] framework = "^2.0.0" and protocol = "^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.

  4. Plugin protocol invocation: split by tier.

  5. WASM plugins: direct function calls via wazero exports. No protocol — in-process invocation.
  6. OCI plugins: gRPC over Unix socket via the /samuel-bridge mount. Protobuf schema at api/proto/plugin/v1/. Per [[0001|RFD 0001]] resolved decision #3.

  7. 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.
  • [[../../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 *Error and lock
  • PRD 0002 (Core) — implements Plugin interface, Orchestrator, SamuelComponent
  • PRD 0003 (Plugin Loader) — implements the three plugin tier structs