Skip to content

actions

import "github.com/alehatsman/mooncake/internal/actions"

Package actions provides the action handler system for mooncake.

# Overview

The actions package defines a standard interface (Handler) that all action implementations must follow, along with a registry for discovering handlers at runtime.

# Architecture

Actions are implemented as packages under internal/actions/. Each action package provides a Handler implementation that is registered globally on import via an init() function.

The executor looks up handlers from the registry based on the action type determined from the step configuration.

# Backward Compatibility

This new system is designed to work alongside the existing action implementations. The Step struct retains all existing action fields (Shell, File, Template, etc.), and actions are migrated incrementally to the new Handler interface.

# Creating a New Action

To create a new action handler:

  1. Create a package under internal/actions/ (e.g., internal/actions/notify)

  2. Implement the Handler interface:

type Handler struct{}

func (h *Handler) Metadata() actions.ActionMetadata {
    return actions.ActionMetadata{
        Name:           "notify",
        Description:    "Send notifications",
        Category:       actions.CategorySystem,
        SupportsDryRun: true,
    }
}

func (h *Handler) Validate(step *config.Step) error {
    // Validate step.Notify config
    return nil
}

func (h *Handler) Execute(ctx *executor.ExecutionContext, step *config.Step) (*executor.Result, error) {
    // Implement action logic
    return &executor.Result{Changed: true}, nil
}

func (h *Handler) DryRun(ctx *executor.ExecutionContext, step *config.Step) error {
    ctx.Logger.Infof("  [DRY-RUN] Would send notification")
    return nil
}
  1. Register the handler in init():
func init() {
    actions.Register(&Handler{})
}
  1. Import the package in the executor to ensure registration:
import _ "github.com/alehatsman/mooncake/internal/actions/notify"

# Migration Strategy

Existing actions are being migrated incrementally:

- Phase 1: Create Handler implementations for simple actions (print, vars)
- Phase 2: Migrate complex actions (shell, file, template)
- Phase 3: Migrate specialized actions (service, assert, preset)
- Phase 4: Remove legacy code paths

During migration, both old and new implementations coexist. The executor checks if a handler is registered and prefers it, falling back to legacy implementations for non-migrated actions.

Package actions provides the handler interface and registry for mooncake actions.

The actions package defines a standard interface that all action handlers must implement, along with a registry system for discovering and dispatching to handlers at runtime.

To create a new action handler: 1. Create a new package under internal/actions (e.g., internal/actions/notify) 2. Implement the Handler interface 3. Register your handler in an init() function 4. The handler will be automatically available for use

Example:

package notify

import "github.com/alehatsman/mooncake/internal/actions"

type Handler struct{}

func init() {
    actions.Register(&Handler{})
}

func (h *Handler) Metadata() actions.ActionMetadata {
    return actions.ActionMetadata{
        Name:        "notify",
        Description: "Send notifications",
        Category:    actions.CategorySystem,
    }
}

// ... implement other interface methods

Index

Variables

SystemPathPrefixes are POSIX directories that conventionally require root privileges to write to. Used by handlers' Permissions() to declare Sudo=true for steps targeting system locations, so the executor preflight can surface a friendly error BEFORE the run rather than EACCES mid-run.

Exported so every file-family handler (file.write, file.template, file.copy, file.download, file.unarchive) can share one canonical list rather than redeclaring it per-package. Extend here when a new system root needs guarding; tests in each handler's permissions_test.go pick up the new prefix automatically.

var SystemPathPrefixes = []string{
    "/etc/",
    "/usr/",
    "/var/",
    "/opt/",
    "/boot/",
    "/root/",
    "/lib/",
    "/lib64/",
    "/sbin/",
    "/bin/",
    "/srv/",
}

func Count

func Count() int

Count returns the number of handlers in the global registry.

func EvaluateBoolExpression

func EvaluateBoolExpression(ctx Context, fieldName, expression string, evalContext map[string]interface{}) (bool, error)

EvaluateBoolExpression renders `expression` through the Context's template engine, evaluates the rendered string through the Context's expression evaluator, and asserts the result is a bool. The `fieldName` argument tags the error message so callers (failed_when / changed_when / unless / etc.) don't have to wrap the error with the same prefix.

Previously each of the shell, command, and assert handlers carried its own copy of this helper. The shell variant tagged errors with a field name; command + assert wrapped the inner error with the field name at the call site. Both shapes collapse here: handler callers pass the field name, the helper produces a single uniform error format, and the three copies become one.

func GetActionHint

func GetActionHint(actionName string, missingField string) string

GetActionHint generates a helpful hint for an action based on the schema. It includes: - Action description - Required parameters with descriptions - Optional parameters with descriptions - Examples where available

func GetFieldExample

func GetFieldExample(actionName, fieldName string) string

GetFieldExample returns an example value for a field based on schema

func Has

func Has(actionType string) bool

Has checks if a handler exists in the global registry.

func IsCoster

func IsCoster(h Handler) bool

func IsDiffer

func IsDiffer(h Handler) bool

IsDiffer / IsReverser / IsCoster / IsPermitter report whether h natively implements the corresponding sub-interface (without returning the default wrapper). Useful for capability reporting in `mooncake actions list` / docs generation / metadata APIs, where we want to distinguish "implements natively" from "has a default."

func IsPermitter

func IsPermitter(h Handler) bool

func IsReverser

func IsReverser(h Handler) bool

func ObserveValueToMap

func ObserveValueToMap(v any) any

ObserveValueToMap converts an observe handler's typed Value struct into a map[string]any keyed by the struct's json tags. The template engine can't reflect through arbitrary Go struct fields, so every observe handler should round-trip its Value through this helper before publishing to Result.Data — that's what makes `{{ name.value.\<field> }}` resolve at apply time.

On marshal failure (e.g. value contains an unmarshallable type) returns the original value unchanged. Defensive — observe handlers control their own Value types, so this path should never fire in practice.

func PathNeedsSudo

func PathNeedsSudo(p string) bool

PathNeedsSudo reports whether p falls under any directory in SystemPathPrefixes. Conservative: returns false for empty paths, relative paths, templated paths (containing "{{"), and anything outside the known roots — the runtime EACCES path remains the backstop for false negatives we can't predict at plan time.

Callers should use this from Permissions() implementations:

func (Handler) Permissions(step *config.Step) actions.PermissionSet {
    var ps actions.PermissionSet
    if actions.PathNeedsSudo(dest) { ps.Sudo = true }
    ...
}

func Register

func Register(handler Handler)

Register registers a handler in the global registry. This is the most common way to register handlers from init() functions. Panics if registration fails (e.g., duplicate handler name).

Example:

func init() {
    actions.Register(&MyHandler{})
}

type Action

Action names a primitive an Effect represents. Used in plan output, logging, and event emission. See Performer.

type Action string
const (
    ActionMkdir     Action = "mkdir"
    ActionWriteFile Action = "write_file"
    ActionCopyFile  Action = "copy_file"
    ActionSymlink   Action = "symlink"
    ActionHardlink  Action = "hardlink"
    ActionTouch     Action = "touch"
    ActionRemove    Action = "remove"
    ActionChmod     Action = "chmod"
    ActionChown     Action = "chown"
    ActionRunCmd    Action = "run_command"
)

type ActionCategory

ActionCategory groups related actions by their primary function.

type ActionCategory string
const (
    // CategoryCommand represents actions that execute commands (shell, command)
    CategoryCommand ActionCategory = "command"

    // CategoryFile represents actions that manipulate files (file, template, copy, download)
    CategoryFile ActionCategory = "file"

    // CategorySystem represents system-level actions (service, assert, preset)
    CategorySystem ActionCategory = "system"

    // CategoryData represents data manipulation actions (vars, include_vars)
    CategoryData ActionCategory = "data"

    // CategoryNetwork represents network-related actions (download, http requests)
    CategoryNetwork ActionCategory = "network"

    // CategoryOutput represents output/display actions (print)
    CategoryOutput ActionCategory = "output"
)

type ActionDefinition

ActionDefinition represents an action's schema definition

type ActionDefinition struct {
    Type        string                    `json:"type"`
    Description string                    `json:"description"`
    Properties  map[string]PropertySchema `json:"properties"`
    Required    []string                  `json:"required"`
}

type ActionMetadata

ActionMetadata describes an action type and its capabilities.

type ActionMetadata struct {
    // Name is the action name as it appears in YAML (e.g., "shell", "file", "notify")
    Name string

    // Description is a human-readable description of what this action does
    Description string

    // Category groups related actions (command, file, system, etc.)
    Category ActionCategory

    // SupportsDryRun indicates whether this action can be executed in dry-run mode
    SupportsDryRun bool

    // SupportsBecome indicates whether this action supports privilege escalation (sudo)
    SupportsBecome bool

    // EmitsEvents lists the event types this action emits (e.g., "file.created", "notify.sent")
    EmitsEvents []string

    // Version is the action implementation version (semantic versioning)
    Version string

    // SupportedPlatforms lists the operating systems this action supports.
    // Valid values: "linux", "darwin", "windows", "freebsd", "openbsd", "netbsd", "dragonfly", "solaris", "aix"
    // Empty list means all platforms are supported
    SupportedPlatforms []string

    // RequiresSudo indicates whether this action typically requires elevated privileges.
    // This is informational - actual privilege requirements may vary based on the operation.
    RequiresSudo bool

    // ImplementsCheck indicates whether this action implements idempotency checks.
    // Actions with idempotency checks verify current state before making changes.
    ImplementsCheck bool

    // ImplementsDiff / ImplementsCost / ImplementsReverse /
    // ImplementsPermissions report whether the handler natively
    // implements the spec-22 four-method ABI sub-interfaces. Populated
    // centrally in Registry.List() from the IsDiffer/IsCoster/
    // IsReverser/IsPermitter helpers (registry_abi.go) — per-handler
    // authors do not set these by hand; the registry derives them
    // from the live interface satisfaction so the columns stay
    // honest as new methods land. Drives proposal-05 capability
    // columns in `mooncake actions list` and the x-implements-*
    // extensions emitted by `mooncake schema generate`.
    ImplementsDiff        bool
    ImplementsCost        bool
    ImplementsReverse     bool
    ImplementsPermissions bool

    // CaptureInPlan declares that this action's result is safe to bind into
    // Scope.Results during plan mode. Reserved for side-effect-free /
    // observation-only actions whose result is informative (e.g. read.json,
    // read.yaml). Default false: mutation actions must not affect vars during
    // plan. See spec-37.
    CaptureInPlan bool

    // Examples is an ordered list of hand-written YAML snippets shown by
    // `mooncake actions show <verb>`. Each entry should be a complete
    // step (one or more `- verb:` blocks) the user can copy-paste into a
    // playbook. When non-empty, the renderer prints these instead of the
    // synthetic schema-derived minimum example. Multi-line entries are
    // printed verbatim so authors control wrapping and field order.
    Examples []string
}

func List

func List() []ActionMetadata

List returns all handlers from the global registry.

type Context

Context provides the execution environment for action handlers.

Context is the primary interface through which handlers interact with the mooncake runtime. It provides access to: - Template rendering (Jinja2-like syntax with variables and filters) - Expression evaluation (when/changed_when/failed_when conditions) - Logging (structured output to TUI or text) - Variables (step vars, global vars, facts, registered results) - Event publishing (for observability and artifacts) - Execution mode (dry-run vs actual execution)

This interface avoids circular imports between actions and executor packages.

Example usage in a handler:

func (h *Handler) Execute(ctx actions.Context, step *config.Step) (actions.Result, error) {
    // Render template strings
    path, err := ctx.Template().RenderString(step.FileWrite.Path, ctx.Variables())

    // Log progress
    ctx.Logger().Infof("Creating file at %s", path)

    // Emit events for observability
    ctx.EventPublisher().Publish(events.Event{
        Type: events.EventFileCreated,
        Data: events.FileOperationData{Path: path},
    })

    // Return result
    result := executor.NewResult()
    result.SetChanged(true)
    return result, nil
}
type Context interface {
    // Template returns the template renderer for processing Jinja2-like templates.
    //
    // Use this to render:
    //   - Path strings with variables: "{{ home }}/{{ item }}"
    //   - Content with logic: "{% if os == 'linux' %}...{% endif %}"
    //   - Filters: "{{ path | expanduser }}"
    //
    // The renderer has access to all variables in scope (step vars, globals, facts).
    Template() template.Renderer

    // Evaluator returns the expression evaluator for conditions.
    //
    // Use this to evaluate:
    //   - when: "os == 'linux' && arch == 'amd64'"
    //   - changed_when: "result.rc == 0 and 'changed' in result.stdout"
    //   - failed_when: "result.rc != 0 and result.rc != 5"
    //
    // Returns interface{} which should be cast to bool for conditions.
    Evaluator() expression.Evaluator

    // Logger returns the logger for handler output.
    //
    // Use levels appropriately:
    //   - Infof: User-visible progress ("Installing package nginx")
    //   - Debugf: Detailed info ("Command: apt install nginx")
    //   - Warnf: Non-fatal issues ("File already exists, skipping")
    //   - Errorf: Failures ("Failed to create directory: permission denied")
    //
    // Output is formatted for TUI or text mode automatically.
    Logger() logger.Logger

    // Variables returns all variables in the current scope.
    //
    // Includes:
    //   - Step-level vars (defined in step.Vars)
    //   - Global vars (from vars actions)
    //   - System facts (os, arch, cpu_cores, memory_total_mb, etc.)
    //   - Registered results (from register: field on previous steps)
    //   - Loop context (item, item_index when in with_items/with_filetree)
    //
    // Keys are strings, values are interface{} (string, int, bool, []interface{}, map[string]interface{}).
    Variables() map[string]interface{}

    // EventPublisher returns the event publisher for observability.
    //
    // Emit events for:
    //   - State changes (EventFileCreated, EventServiceStarted)
    //   - Progress tracking (custom events for long operations)
    //   - Artifact generation (paths to created files)
    //
    // Events are consumed by:
    //   - Artifact collector (for rollback support)
    //   - External observers (CI/CD integrations)
    //   - Audit logs
    EventPublisher() events.Publisher

    // Mode reports the dispatch mode for this context. Handlers
    // implementing Runner consult this to decide whether to perform side
    // effects (ModeApply) or only inspect state (ModePlan).
    Mode() Mode

    // Effects returns a Performer wired to this context. Handlers
    // implementing Runner should call Performer methods instead of os.*
    // directly so that ModePlan vs ModeApply is decided in one place.
    // The returned Performer is cheap to construct and may be called
    // multiple times.
    Effects() Performer

    // Privileged returns the spec-72 Layer C escalation primitive,
    // pre-bound by dispatchRunner to the current step's AsUser.
    // Handlers should call ctx.Privileged().Run(...) for any shell-out
    // that needs escalation; the primitive decides the sudo wrap
    // (none / sudo / sudo -u <name>) from the bound AsUser. Handlers
    // must NOT read step.AsUser or step.ShouldBecome() for execution
    // decisions — the primitive sees them transparently. See
    // security.Privileged and spec-72 for the rationale.
    Privileged() *security.Privileged

    // StepID returns the unique ID of the currently executing step.
    //
    // Format: "step-{global_step_number}"
    //
    // Use this when:
    //   - Emitting events (so they're associated with the step)
    //   - Creating temporary files (include step ID to avoid conflicts)
    //   - Logging (though step ID is usually added automatically)
    StepID() string

    // MergeUserVars merges the provided key-value pairs into the user variable scope.
    // Use this instead of mutating the map returned by Variables() directly,
    // so that the write goes to the correct typed bucket (Scope.User when available).
    MergeUserVars(vars map[string]interface{})

    // Ctx returns the run-wide context driving this apply. Handlers
    // MUST plumb this into any external call (exec.CommandContext,
    // http.NewRequestWithContext, net.Dialer{...}.DialContext, …) so
    // the apply observes SIGINT / fleet kill / MCP shutdown / caller
    // cancel. Per-step timeouts compose on top via
    // context.WithTimeout(ctx.Ctx(), step.Timeout).
    //
    // Producers attach typed cancel causes via context.WithCancelCause
    // (see executor.ErrCancelSignal / ErrCancelFleet / ErrCancelMCP);
    // syncResultEnvelope then classifies the resulting Cancelled
    // step's CancelledReason. F2 (handler-ctx threading) is what
    // makes that classification observable end-to-end — without it,
    // handlers that ignore ctx complete normally during a cancel and
    // the recap never says cancelled=N.
    //
    // May return context.Background() in detached contexts (Reverse
    // plans, certain test setups) — handlers should treat it as
    // non-nil but always-cancellable in principle.
    Ctx() context.Context
}

type CostEstimate

CostEstimate is a coarse, pre-execution signal of a step's blast radius. Consumed by run-recap (averaged risk + summed resources), JSON plan output (per-step), and future policy layers. Not a hard gate — informational unless something downstream chooses to enforce.

type CostEstimate struct {
    // Resources is a lower-bound count of distinct things this step
    // touches (files, packages, service units, ...). Lower bound:
    // dynamic actions may touch more. -1 = unknown.
    Resources int `json:"resources"`

    // Bytes is an order-of-magnitude estimate of bytes written or
    // mutated by this step. -1 = unknown / not applicable.
    Bytes int64 `json:"bytes"`

    // Reversible reports whether the handler implements Reverser
    // (and would therefore return a non-nil Step from Reverse).
    // Mirrors what `(h, ok := h.(Reverser)); ok` would report.
    Reversible bool `json:"reversible"`

    // Risk is a 1..10 informational band:
    //   1–3   safe (read-only, idempotent writes to scratch)
    //   4–6   routine (config writes, package installs)
    //   7–9   high impact (service restarts, kernel params)
    //   10    destructive (deletes, drops, rm -rf)
    // Default fallback for handlers that don't implement Coster is 5.
    Risk int `json:"risk"`
}

type Coster

Coster is the optional interface for pre-execution blast-radius signal. Handlers that don't implement it get a neutral default of Risk=5 with Reversible inferred from whether Reverser is implemented.

type Coster interface {
    Cost(ctx Context, step *config.Step) (CostEstimate, error)
}

func ResolveCoster

func ResolveCoster(h Handler) Coster

ResolveCoster returns h as a Coster. If h doesn't implement Coster, a neutral default is returned (Risk=5, Reversible inferred from whether h implements Reverser, Resources=-1, Bytes=-1).

type CronDiff

CronDiff is the typed Before/After payload when an actions.Diff describes an os.cron mutation. The handler reports intent only (no /etc/cron.d read at plan time). Consumers dispatch on Resource.Attributes["kind"] == "os.cron".

type CronDiff struct {
    // Name is the cron entry's filename in /etc/cron.d (identity).
    Name string `json:"name,omitempty"`

    // State is the desired terminal state: "present" or "absent".
    State string `json:"state,omitempty"`

    // User the command runs as. Empty maps to root.
    User string `json:"user,omitempty"`

    // Schedule is the rendered cron schedule (five whitespace-
    // joined fields when the step uses minute/hour/... pieces).
    Schedule string `json:"schedule,omitempty"`

    // Command is the command line the entry will execute.
    Command string `json:"command,omitempty"`
}

type Diff

Diff is a machine-readable structural delta of what a step would change. Returned by handlers implementing the Differ interface; consumed by mooncake plan --format json, the agent SDK, and (later) the policy / transaction layers.

Diff is intentionally distinct from snapshot.Diff (which compares two full system snapshots). A Diff describes ONE step's predicted change to ONE resource; Diff describes the before/after of an entire box.

type Diff struct {
    // Resource identifies the thing being changed: a file path, a
    // package name, a service unit, an external object reference.
    Resource ResourceRef `json:"resource"`

    // Operation classifies the change at a coarse level. Combined
    // with Before / After it tells a consumer "what kind of change"
    // without needing to inspect the typed payload.
    Operation Operation `json:"operation"`

    // Before is the pre-change state, action-defined shape. nil for
    // OpCreate (nothing was there). For file.write: a FileSnapshot
    // (path + size + sha256 + mode + ...). For pkg: typically nil
    // (the handler reports intent only and does not probe the
    // package manager at plan time).
    Before any `json:"before,omitempty"`

    // After is the post-change state. nil for OpDelete. Same typed
    // shape as Before per action; consumers learn the type from
    // Resource.Kind.
    After any `json:"after,omitempty"`

    // Lines, when populated, is a unified-diff-style breakdown for
    // textual content. Mostly used by file.write / file.template /
    // text.* handlers. Empty for non-textual actions.
    Lines []DiffLine `json:"lines,omitempty"`
}

type DiffLine

DiffLine is one entry in a unified-diff-style breakdown of text content. Op uses single-character markers matching `diff -u` output: "+" added, "-" removed, " " context. LineNumber refers to the post-change file when Op != "-", to the pre-change file when "-", and to either when " ".

type DiffLine struct {
    Op         DiffOp `json:"op"`
    Text       string `json:"text"`
    LineNumber int    `json:"line_number,omitempty"`
}

type DiffOp

DiffOp is the one-character marker for a DiffLine.

type DiffOp string
const (
    DiffOpAdd     DiffOp = "+"
    DiffOpRemove  DiffOp = "-"
    DiffOpContext DiffOp = " "
)

type Differ

Differ is the optional interface handlers implement to produce a structured per-step Diff. Called in plan mode by the planner; consumed by JSON plan output, the agent SDK, and any UI past `mooncake plan`.

Handlers without a Differ implementation get a coarse default (Operation-only, derived from Run's plan-mode Result) via ResolveDiffer.

type Differ interface {
    Diff(ctx Context, step *config.Step) (Diff, error)
}

func ResolveDiffer

func ResolveDiffer(h Handler) Differ

ResolveDiffer returns h as a Differ. If h doesn't implement Differ, a default wrapper is returned that derives a coarse Diff from h.Run in plan mode.

type Effect

Effect is the result of a Performer call.

Field semantics by mode:

ModeApply:
    Performed   true if a side effect actually happened
    AlreadyOk   true if the target was already in desired state (no-op)
    WouldChange unused (false)
    Err         any error from the underlying syscall / command

ModePlan:
    Performed   false (no side effects in plan mode)
    AlreadyOk   true if the target is already in desired state
    WouldChange true if applying ModeApply would change state
    Err         any error encountered while *inspecting* state

Performed and WouldChange are mutually exclusive; AlreadyOk is set when the operation would be a no-op in either mode.

type Effect struct {
    Action      Action
    Path        string
    Reason      string
    Performed   bool
    WouldChange bool
    AlreadyOk   bool
    Err         error
    Detail      any
}

func (Effect) Changed

func (e Effect) Changed() bool

Changed reports whether this Effect represents a state change — either performed (ModeApply) or predicted (ModePlan).

type FirewallDiff

FirewallDiff is the typed Before/After payload when an actions.Diff describes an os.firewall mutation. The handler reports intent only — rule details are intentionally NOT echoed in After because rule lists may carry security-sensitive ports / source addresses. The renderer surfaces backend + count + state. Consumers dispatch on Resource.Attributes["kind"] == "os.firewall".

type FirewallDiff struct {
    // Backend is the firewall backend (v1: "ufw"; "auto" when the
    // step did not pin one).
    Backend string `json:"backend,omitempty"`

    // State is the desired terminal state: "present" or "absent".
    State string `json:"state,omitempty"`

    // RuleCount is the number of rules in the step (1 for single
    // `rule:` form, len(`rules:`) otherwise). Rule details live in
    // the step body, not here.
    RuleCount int `json:"rule_count"`
}

type GitCheckoutDiff

GitCheckoutDiff is the typed Before/After payload when an actions.Diff describes a git.checkout mutation. Consumers dispatch on Resource.Attributes["kind"] == "git.checkout". Before holds the observed HeadSHA when dest is a git repo at plan time; After holds the requested Ref. After.HeadSHA is empty at plan time — ref resolution happens at apply.

type GitCheckoutDiff struct {
    // Dest is the working-copy path (identity).
    Dest string `json:"dest,omitempty"`

    // Ref is the requested ref (branch / tag / sha).
    Ref string `json:"ref,omitempty"`

    // HeadSHA is the observed HEAD sha. Populated in Before when
    // dest is a git repo at plan time.
    HeadSHA string `json:"head_sha,omitempty"`
}

type GitCloneDiff

GitCloneDiff is the typed Before/After payload when an actions.Diff describes a git.clone mutation. Before holds the observed HeadSHA when dest is already a git repo at plan time; After holds the requested repo URL + ref. After.HeadSHA is empty at plan time — resolution happens at apply. Consumers dispatch on Resource.Attributes["kind"] == "git.clone".

type GitCloneDiff struct {
    // Dest is the working-copy path (identity).
    Dest string `json:"dest,omitempty"`

    // Repo is the remote URL.
    Repo string `json:"repo,omitempty"`

    // Ref is the requested ref (branch / tag / sha). Empty when
    // the step pins no ref — HEAD of the default branch wins.
    Ref string `json:"ref,omitempty"`

    // HeadSHA is the observed HEAD sha. Populated in Before when
    // dest is already a git repo at plan time.
    HeadSHA string `json:"head_sha,omitempty"`
}

type GitConfigDiff

GitConfigDiff is the typed Before/After payload when an actions.Diff describes a git.config mutation. The handler reports declared intent (set / unset per key) — it does not shell out to git at plan time to compare current values. Consumers dispatch on Resource.Attributes["kind"] == "git.config".

type GitConfigDiff struct {
    // Scope is "local" | "global" | "system".
    Scope string `json:"scope,omitempty"`

    // Repo is the working-copy path for local scope; empty for
    // global / system.
    Repo string `json:"repo,omitempty"`

    // Entries is the per-key intent: each carries Key + Value +
    // Op ("set" | "unset"). Sorted by Key for stable plan output.
    Entries []GitConfigEntry `json:"entries,omitempty"`
}

type GitConfigEntry

GitConfigEntry is one key-level intent in a GitConfigDiff.

type GitConfigEntry struct {
    Key   string `json:"key"`
    Value string `json:"value,omitempty"`
    Op    string `json:"op,omitempty"` // "set" | "unset"
}

type GroupDiff

GroupDiff is the typed Before/After payload when an actions.Diff describes an os.group mutation. Same intent-only convention as UserDiff. Consumers dispatch on Resource.Attributes["kind"] == "os.group".

type GroupDiff struct {
    // Name is the group name (idempotency identity).
    Name string `json:"name,omitempty"`

    // State is the desired terminal state: "present" or "absent".
    State string `json:"state,omitempty"`

    // GID, when set, is the requested numeric gid.
    GID *int `json:"gid,omitempty"`

    // System mirrors the system-group flag.
    System bool `json:"system,omitempty"`
}

type Handler

Handler defines the interface that all action handlers must implement.

A handler is responsible for: - Validating action configuration - Executing the action - Handling dry-run mode - Emitting appropriate events - Returning results

Handlers should be stateless - all execution state is passed via ExecutionContext.

Spec 16 collapsed the previous Execute / DryRun / Check trio into a single Run(ctx, step) method (the Runner interface). The legacy methods may still exist on concrete handler types — they are no longer part of the contract.

type Handler interface {
    // Metadata returns metadata describing this action type.
    Metadata() ActionMetadata

    // Validate checks if the step configuration is valid for this action.
    // Called before Run to fail fast on configuration errors.
    Validate(step *config.Step) error

    // Run executes the action when ctx.Mode() is ModeApply, or
    // inspects state and returns a prediction when ctx.Mode() is
    // ModePlan. Implementations:
    //
    //   - emit appropriate events via ctx.EventPublisher() (execute mode)
    //   - render templates via ctx.Template()
    //   - use ctx.Logger() for logging
    //   - return Result with Changed=true (execute) or
    //     WouldChange=true (plan) when state would change
    //   - route filesystem mutations through ctx.Effects() so that
    //     plan and execute modes share the same predicates
    //
    // Returns an error only on unrecoverable failure.
    Run(ctx Context, step *config.Step) (Result, error)
}

func Get

func Get(actionType string) (Handler, bool)

Get retrieves a handler from the global registry.

func NewHandlerFunc

func NewHandlerFunc(metadata ActionMetadata, validate func(*config.Step) error, execute func(Context, *config.Step) (Result, error), dryRun func(Context, *config.Step) error) Handler

NewHandlerFunc creates a Handler from function implementations.

type HandlerFunc

HandlerFunc is a function type that implements Handler for simple actions. This allows creating handlers without defining a new type.

type HandlerFunc struct {
    // contains filtered or unexported fields
}

func (*HandlerFunc) DryRun

func (h *HandlerFunc) DryRun(ctx Context, step *config.Step) error

func (*HandlerFunc) Execute

func (h *HandlerFunc) Execute(ctx Context, step *config.Step) (Result, error)

func (*HandlerFunc) Metadata

func (h *HandlerFunc) Metadata() ActionMetadata

func (*HandlerFunc) Run

func (h *HandlerFunc) Run(ctx Context, step *config.Step) (Result, error)

Run satisfies the Spec 16 Handler contract. In ModePlan it reports "not checkable" by default; in ModeApply it delegates to the underlying execute function. HandlerFunc users wanting accurate plan-mode behavior should construct a typed Handler with its own Run method instead.

func (*HandlerFunc) Validate

func (h *HandlerFunc) Validate(step *config.Step) error

type Mode

Mode is the high-level dispatch mode for an action handler.

Spec 16 collapsed the previous parallel non-mutating paths (Handler.DryRun and Handler.Check) into a single ModePlan. ModeApply is the normal mutating path.

Spec 21 renamed the apply-mode constant (formerly ModeExecute) to ModeApply for alignment with the CLI's `mooncake apply` subcommand and modern IaC vocabulary (Terraform et al.).

type Mode int
const (
    // ModeApply performs real work: side effects, mutations, commands.
    ModeApply Mode = iota
    // ModePlan inspects target state and predicts what would change.
    // No side effects. Replaces the legacy DryRun + Check methods.
    ModePlan
)

func (Mode) String

func (m Mode) String() string

String returns a stable human-readable name for the mode.

type MountDiff

MountDiff is the typed Before/After payload when an actions.Diff describes an os.mount mutation. The handler reports intent only (no /etc/fstab or /proc/mounts read at plan time). Consumers dispatch on Resource.Attributes["kind"] == "os.mount".

type MountDiff struct {
    // Src is the device / spec (UUID=..., LABEL=..., tmpfs, etc.).
    Src string `json:"src,omitempty"`

    // Dest is the mount point (the idempotency identity).
    Dest string `json:"dest,omitempty"`

    // FSType is the filesystem type (ext4, xfs, tmpfs, nfs, ...).
    FSType string `json:"fstype,omitempty"`

    // State is mounted | unmounted | fstab_only | absent. Empty
    // defaults to "mounted" by handler convention.
    State string `json:"state,omitempty"`

    // Options is the mount-options list (defaults, noatime, ...).
    Options []string `json:"options,omitempty"`
}

type ObserveResult

ObserveResult is the shared envelope returned by every observe.* handler (spec-59). The typed per-handler payload lives in Value; the universal fields (Found, AsOf, Error) wrap it so consumers can branch on observation outcome without knowing the per-handler type.

When a handler returns its result, it stores the ObserveResult under the well-known key "observe" in the executor.Result.Data map (along with the un-wrapped value at "value") so spec-37 `as:` capture exposes both shapes to downstream templates:

- {{ name.value.<field> }}  — the typed per-handler payload
- {{ name.found }}          — universal Found flag
- {{ name.as_of }}          — observation timestamp
- {{ name.error }}          — user-facing error when Found=false

Mutation is forbidden by contract: observe handlers must return Changed=false and declare empty Diff / nil Reverse / Cost{Risk:1} / Permissions{ReadOnly:true} per spec-59's ABI specialization.

type ObserveResult struct {
    // Found is true if the observed resource exists / is reachable.
    // Each handler defines what "found" means in its own contract.
    Found bool `json:"found"`

    // Value is the typed per-handler payload (e.g. PortObservation,
    // ProcessObservation). Stored as any in this generic envelope.
    Value any `json:"value,omitempty"`

    // AsOf is the wall-clock time the observation was taken. Set by
    // the handler, not by the caller.
    AsOf time.Time `json:"as_of"`

    // Error carries a user-facing error string when Found=false because
    // the observation itself failed (DNS error, permission denied,
    // transport failure). Empty when Found=false because the resource
    // genuinely doesn't exist.
    Error string `json:"error,omitempty"`
}

func PlanDeferred

func PlanDeferred(emptyValue any) ObserveResult

PlanDeferred returns the synthetic observation result that every observe.* handler returns in plan mode by default (per spec-59 G4). The shape matches a real observation so downstream templates that reference {{ name.value.\<field> }} don't blow up in plan mode — they see a zero value of the typed Value with Found=false.

Proposal-06: Error stays empty here. A deferred observation is not a probe failure — handlers carry the "would observe X (deferred)" explanation in executor.Result.Reason instead, which surfaces in plan output without lying about whether the step failed.

emptyValue should be a zero-value of the handler's typed payload struct, so templates that index into it still type-check.

type Operation

Operation is a coarse classifier shared by every action's Diff. Lets a UI sort/group changes ("3 creates, 1 update, 0 deletes") without looking at the typed Before/After.

type Operation string
const (
    OpCreate Operation = "create"
    OpUpdate Operation = "update"
    OpDelete Operation = "delete"
    OpNoop   Operation = "noop"
)

type PackageDiff

PackageDiff is the typed Before/After payload when an actions.Diff has Resource.Kind == ResourcePackage. Today's pkg handler reports intent only — it does not probe the package manager from Diff — so After describes the desired terminal state and Before is left nil. A future "with measurement" mode (e.g. `plan --probe`) could fill in Before.Installed once probing is in scope.

type PackageDiff struct {
    // Names are the package names this step targets. Set even when
    // the step uses singular `name:` — we always emit a slice so
    // consumers don't need two code paths.
    Names []string `json:"names,omitempty"`

    // State is the desired terminal state: "present" / "absent" /
    // "latest". Empty maps to "present" by handler convention.
    State string `json:"state,omitempty"`

    // Manager is the explicit package manager when the step pins
    // one (apt/dnf/brew/winget/...). Empty when auto-detect is in
    // play.
    Manager string `json:"manager,omitempty"`
}

type Performer

Performer executes filesystem and command primitives in either ModeApply (real side effects) or ModePlan (state inspection only, returning a prediction).

Spec 16 introduces Performer so that mutating primitives have exactly one site that decides what each operation means in each mode. Handlers should call Performer methods (via ctx-supplied accessor) instead of calling os.* directly.

type Performer interface {
    // Mode reports the mode the Performer will use for the next call.
    Mode() Mode

    // Mkdir ensures a directory exists at path with the given mode.
    // Parents are created as needed.
    Mkdir(path string, mode os.FileMode, opts PerformerOpts) Effect

    // WriteFile writes content to path with the given mode, creating
    // parent directories as needed. Idempotent: if existing content
    // already matches, AlreadyOk is set.
    //
    // content is held in memory by the implementation; use CopyFile
    // instead when the source is already a file on disk.
    WriteFile(path string, content []byte, mode os.FileMode, opts PerformerOpts) Effect

    // CopyFile streams the file at src to dest with the given mode,
    // creating parent directories as needed. Memory usage is bounded
    // regardless of file size — the source is never loaded into a
    // single []byte (unlike WriteFile + os.ReadFile, which has been
    // the historical shape used by the copy action).
    //
    // Idempotent: if dest already exists with the same size, content
    // (verified by streaming sha256 of both files), and mode, AlreadyOk
    // is set without performing a copy. This keeps Plan-mode honest
    // for large files without loading them into memory.
    //
    // The src file must exist and be a regular file. dest is created
    // or truncated. Symlinks at src are followed; symlinks at dest are
    // replaced.
    CopyFile(src, dest string, mode os.FileMode, opts PerformerOpts) Effect

    // Symlink creates a symbolic link at path pointing to target. If a
    // link already exists with the correct target, AlreadyOk is set.
    Symlink(target, path string, opts PerformerOpts) Effect

    // Hardlink creates a hard link at path pointing to target.
    Hardlink(target, path string, opts PerformerOpts) Effect

    // Touch updates mtime, creating an empty file with the given mode
    // if absent. Not idempotent: WouldChange is always true in ModePlan.
    Touch(path string, mode os.FileMode, opts PerformerOpts) Effect

    // Remove deletes path. If recursive is true, removes directories
    // and their contents.
    Remove(path string, recursive bool, opts PerformerOpts) Effect

    // Chmod sets the permission bits on path.
    Chmod(path string, mode os.FileMode, opts PerformerOpts) Effect

    // Chown sets owner and group on path. Empty owner or group leaves
    // that side unchanged. Owner/group may be names or numeric IDs.
    Chown(path, owner, group string, opts PerformerOpts) Effect
}

type PerformerOpts

PerformerOpts carries optional flags that apply to most Performer calls. Become/BecomeUser were removed in spec-72 Layer C: the step's AsUser is bound onto the Performer at ec.Effects() time and drives escalation transparently, so handlers no longer pass a per-call become flag.

Force: when set, Symlink and Hardlink replace an existing path that is not already of the correct link type (e.g. a directory). Without Force, those primitives return an error in that case.

ExplicitMode signals that the caller's `mode` was supplied directly (e.g. `file.copy: mode: '0755'`) rather than derived from the source file. WriteFile/CopyFile use this to decide whether to enforce the requested mode on an existing dest or preserve the dest's current mode (the WriteFile-compatible round-trip default).

type PerformerOpts struct {
    Force        bool
    ExplicitMode bool
}

type PermissionSet

PermissionSet declares the privileges and external dependencies a step needs to run. Consumed by executor preflight (fail-fast if a required binary is missing or Sudo is required and we're not elevated), plan output (surface `requires:` lines per step), and the future policy DSL.

type PermissionSet struct {
    // Sudo: this step requires elevated privileges. Executor checks
    // effective UID (or that as_user resolves to root) before run.
    Sudo bool `json:"sudo,omitempty"`

    // Network: this step makes outbound network calls. Informational
    // today; a later policy layer may gate on this.
    Network bool `json:"network,omitempty"`

    // RequiredBinaries: programs that must resolve via exec.LookPath
    // before this step can run. Executor fails preflight with a
    // clear "missing binary X for action Y" message.
    RequiredBinaries []string `json:"required_binaries,omitempty"`

    // FilesystemWrite: declared write paths or globs. "*" = anywhere.
    // Used by policy / UI surfaces — NOT enforced today.
    FilesystemWrite []string `json:"filesystem_write,omitempty"`

    // Notes: free-form human-readable extras for UI display.
    Notes []string `json:"notes,omitempty"`
}

type Permitter

Permitter is the optional interface for declaring required privileges. Cheap to implement (often a static return) and high-leverage: surfaces permission requirements at plan time instead of as runtime failures.

type Permitter interface {
    Permissions(step *config.Step) PermissionSet
}

func ResolvePermitter

func ResolvePermitter(h Handler) Permitter

ResolvePermitter returns h as a Permitter. If h doesn't implement Permitter, an empty PermissionSet is returned — meaning "no special privileges declared." Callers should treat this as "unknown requirements," not "guaranteed safe."

type PkgUpgradeDiff

PkgUpgradeDiff is the typed Before/After payload when an actions.Diff describes a pkg.upgrade mutation. Before is nil — Diff doesn't query the package manager for the per-package version delta. Consumers dispatch on Resource.Attributes["kind"] == "pkg.upgrade".

type PkgUpgradeDiff struct {
    // Names is the explicit subset; empty means full-system upgrade.
    Names []string `json:"names,omitempty"`

    // Autoremove mirrors the step flag.
    Autoremove bool `json:"autoremove,omitempty"`

    // Manager is the explicit package manager when pinned.
    Manager string `json:"manager,omitempty"`

    // FullUpgrade is true when Names is empty (system-wide).
    FullUpgrade bool `json:"full_upgrade,omitempty"`
}

type PropertySchema

PropertySchema represents a property's schema

type PropertySchema struct {
    Type        string `json:"type"`
    Description string `json:"description"`
    Pattern     string `json:"pattern,omitempty"`
}

type RawRunner

RawRunner is the spec-69 phase 2-3 opt-in alternative to Runner. Handlers that implement RawRunner delegate retry-loop and result- override (changed_when / failed_when) responsibility to the executor. RunRaw must:

- Execute exactly one attempt of the action's work.
- Return (Result, error) reflecting the raw outcome; never apply
  failed_when or changed_when overrides itself.
- Be safe to call multiple times in a row when retries are
  configured.

The executor wraps RunRaw in its retry loop (honoring step.Retry fields uniformly across all RawRunner implementations) and then applies overrides once, post-loop. This preserves MT-48 (the retry decision is on the raw exit code, never on the post-failed_when verdict) and MT-62 (backoff strategies honored uniformly).

Handlers that implement both Runner.Run and RawRunner.RunRaw are dispatched via RunRaw — the executor prefers the RawRunner path. Handlers without RawRunner go through the legacy Runner.Run path unchanged; their own internal retry+override logic still applies.

type RawRunner interface {
    RunRaw(ctx Context, step *config.Step) (Result, error)
}

type Registry

Registry manages registered action handlers through a thread-safe map.

The registry pattern enables: 1. Dynamic action discovery - handlers register themselves via init() 2. Loose coupling - executor doesn't import all action packages 3. Extensibility - new actions added without changing executor 4. Thread safety - concurrent access from multiple goroutines

Registration flow: 1. Action package imports actions: import "github.com/.../internal/actions" 2. Action package defines handler: type Handler struct{} 3. Action package registers in init(): func init() { actions.Register(&Handler{}) } 4. Main imports register package: import _ "github.com/.../internal/register" 5. Register package imports all actions: import _ ".../actions/shell" 6. All handlers automatically registered before main() runs

Lookup flow: 1. Executor determines action type from step: actionType := step.DetermineActionType() 2. Executor queries registry: handler, ok := actions.Get(actionType) 3. If found, executor calls: handler.Validate(step), handler.Execute(ctx, step) 4. If not found, executor falls back to legacy implementation

This avoids circular imports because: - actions package defines Handler interface - action implementations (shell, file, etc.) import actions - executor imports actions but NOT action implementations - register package imports action implementations (triggers init()) - cmd imports register (triggers all registrations)

type Registry struct {
    // contains filtered or unexported fields
}

func NewRegistry

func NewRegistry() *Registry

NewRegistry creates a new action registry.

func (*Registry) Count

func (r *Registry) Count() int

Count returns the number of registered handlers.

func (*Registry) Get

func (r *Registry) Get(actionType string) (Handler, bool)

Get retrieves a handler by action type name. Returns the handler and true if found, nil and false otherwise.

func (*Registry) Has

func (r *Registry) Has(actionType string) bool

Has checks if a handler is registered for the given action type.

func (*Registry) List

func (r *Registry) List() []ActionMetadata

List returns metadata for all registered handlers. Useful for introspection and documentation generation.

proposal-05: the four spec-22 ABI capability bools (ImplementsDiff/Cost/Reverse/Permissions) are populated here from the handler's live interface satisfaction — handler authors do not declare them by hand, so the columns can't drift from reality as new methods are added.

func (*Registry) Register

func (r *Registry) Register(handler Handler) error

Register adds a handler to the registry. This is typically called from init() functions in action packages. Returns an error if a handler with the same name is already registered.

type RepoDiff

RepoDiff is the typed Before/After payload when an actions.Diff describes a pkg.repo mutation. The handler reports intent only — reading the existing sources list and matching it to the requested shape is non-trivial across managers and out of scope for the cheap-Diff contract. Before stays nil. Consumers dispatch on Resource.Attributes["kind"] == "pkg.repo".

type RepoDiff struct {
    // Name is the repo identifier (also the on-disk filename).
    Name string `json:"name,omitempty"`

    // State is "present" or "absent". Empty maps to "present"
    // by handler convention.
    State string `json:"state,omitempty"`

    // Driver names the populated manager block: "apt" | "dnf" |
    // "brew" | "" (none).
    Driver string `json:"driver,omitempty"`
}

type ResourceKind

ResourceKind tags a ResourceRef so consumers can dispatch on the expected shape of Before / After. Kept open-ended as a string type so future actions (k8s objects, cloud resources) can add their own kinds without breaking consumers that don't recognize them.

type ResourceKind string
const (
    ResourceFile    ResourceKind = "file"
    ResourcePackage ResourceKind = "package"
    ResourceService ResourceKind = "service"
    ResourceText    ResourceKind = "text"
    ResourceShell   ResourceKind = "shell"
    ResourceVar     ResourceKind = "var"
    ResourceGit     ResourceKind = "git"
    ResourceOther   ResourceKind = "other"
)

type ResourceRef

ResourceRef identifies the target of a step's change. Kind selects the schema of any typed metadata the consumer might need; Identifier is the human-readable handle (path / package name / service unit). Attributes carries optional extras without forcing a per-kind struct.

type ResourceRef struct {
    Kind       ResourceKind      `json:"kind"`
    Identifier string            `json:"identifier"`
    Attributes map[string]string `json:"attributes,omitempty"`
}

type Result

Result represents the outcome of an action execution.

Results track: - Whether changes were made (for idempotency reporting) - Output data (stdout/stderr from commands) - Success/failure status - Custom data (for result registration)

Results can be registered to variables for use in subsequent steps via the register: field.

Example:

result := executor.NewResult()
result.SetChanged(true)  // File was created/modified
result.SetData(map[string]interface{}{
    "path": "/etc/myapp/config.yml",
    "size": 1024,
    "checksum": "sha256:abc123...",
})

// If step has register: myfile, data is available as:
// {{ myfile.changed }} = true
// {{ myfile.path }} = "/etc/myapp/config.yml"

This interface avoids circular imports between actions and executor packages.

type Result interface {
    // SetChanged marks whether this action modified system state.
    //
    // Set to true if the action:
    //   - Created/modified/deleted files or directories
    //   - Started/stopped/restarted services
    //   - Installed/removed packages
    //   - Executed commands that changed state
    //
    // Set to false if the action:
    //   - Found state already as desired (idempotent)
    //   - Only read/queried information
    //   - Failed before making changes
    //
    // Changed count is reported in run summary and used for idempotency tracking.
    SetChanged(changed bool)

    // SetStdout captures standard output from the action.
    //
    // Used primarily by shell/command actions. Output is:
    //   - Available in registered results as {{ result.stdout }}
    //   - Shown in TUI output view
    //   - Logged to artifacts
    //   - Used in changed_when/failed_when expressions
    SetStdout(stdout string)

    // SetStderr captures standard error from the action.
    //
    // Used primarily by shell/command actions. Error output is:
    //   - Available in registered results as {{ result.stderr }}
    //   - Shown in TUI output view (usually in red)
    //   - Logged to artifacts
    //   - Used in changed_when/failed_when expressions
    SetStderr(stderr string)

    // SetFailed marks the result as failed.
    //
    // Usually you should return an error instead of calling this. Use this when:
    //   - The action completed but didn't achieve desired state
    //   - failed_when expression evaluated to true
    //   - Assertion failed (assert action)
    //
    // Failed steps:
    //   - Increment failure count in run summary
    //   - Stop execution (unless ignore_errors: true)
    //   - Are highlighted in TUI
    SetFailed(failed bool)

    // SetData attaches custom data to the result.
    //
    // Data becomes available when the result is registered via register: field.
    //
    // Example:
    //
    //  result.SetData(map[string]interface{}{
    //      "checksum": "sha256:abc123",
    //      "size_bytes": 1024,
    //      "format": "json",
    //  })
    //
    // Then in subsequent steps:
    //    when: myfile.checksum == "sha256:abc123"
    //    shell: echo "File size: {{ myfile.size_bytes }}"
    //
    // Keys should be snake_case. Values should be JSON-serializable.
    SetData(data map[string]interface{})

    // RegisterTo registers this result to the variables map.
    //
    // Called automatically by the executor when a step has register: field.
    // Creates a map in variables with:
    //   - changed: bool
    //   - failed: bool
    //   - stdout: string (if set)
    //   - stderr: string (if set)
    //   - rc: int (if applicable)
    //   - ...custom data from SetData()
    //
    // Handlers typically don't call this directly.
    RegisterTo(variables map[string]interface{}, name string)
}

type Retryable

Retryable is an optional companion to RawRunner. Handlers that implement it can decide per-attempt whether a retry should be tried — useful for actions like http.request where 5xx/429/ timeout errors are retryable but 4xx errors aren't.

The decision input is (result, err, step): - result: whatever RunRaw returned. May be nil on pre-exec failures. Carries Data which handlers like http.request populate with status_code so retry policy can branch on it without inventing a typed-error wrapper. - err: the raw outcome of the most recent attempt. - step: the YAML step (retry policy fields, action config).

When absent, the executor's retry loop treats every non-nil err as retryable up to step.RetryAttempts().

type Retryable interface {
    IsRetryable(result Result, err error, step *config.Step) bool
}

type Reverser

Reverser is the optional interface handlers implement to declare how their effect is undone. Spec-30 (`transaction:` blocks) is the primary consumer: on transaction failure the executor walks completed steps in reverse order and applies the Step each Reverser returns.

Result-arg note: Reverse takes the apply-time Result so the handler can use any data captured during Run (e.g. the path of a backup file, the previous package version). For purely structural reverses the arg may be ignored.

Return semantics: - (step, nil) — apply this Step to undo - (nil, nil) — no reverse needed (e.g. step was a noop) - (nil, error) — handler declares itself irreversible; rollback requires manual intervention. Transaction will surface this.

type Reverser interface {
    Reverse(ctx Context, step *config.Step, result Result) (*config.Step, error)
}

func ResolveReverser

func ResolveReverser(h Handler) Reverser

ResolveReverser returns h as a Reverser. If h doesn't implement Reverser, a default that returns (nil, nil) is returned — i.e. "no reverse needed", NOT "irreversible." Handlers that want to declare themselves irreversible should implement Reverser and return an explicit error.

type Runner

Runner is the unified handler entry point introduced by Spec 16. The Handler interface now embeds Run as a required method; Runner remains as a named type alias for clarity in call sites that want to express "the Run capability" specifically.

type Runner interface {
    Run(ctx Context, step *config.Step) (Result, error)
}

type SSHKeyDiff

SSHKeyDiff is the typed Before/After payload when an actions.Diff describes an os.ssh_key mutation. Intent-only (no authorized_keys read at plan time). Key material is intentionally NOT echoed — user-supplied secrets adjacent. Consumers dispatch on Resource.Attributes["kind"] == "os.ssh_key".

type SSHKeyDiff struct {
    // User is the target account (identity).
    User string `json:"user,omitempty"`

    // State is "present" or "absent". Empty maps to "present".
    State string `json:"state,omitempty"`

    // KeyCount is the number of keys this step targets. Material
    // itself is suppressed for security.
    KeyCount int `json:"key_count"`

    // Path, when set, is the custom authorized_keys location.
    Path string `json:"path,omitempty"`

    // Exclusive mirrors the "remove other keys" flag.
    Exclusive bool `json:"exclusive,omitempty"`
}

type Schema

Schema represents the JSON schema structure

type Schema struct {
    Definitions map[string]ActionDefinition `json:"definitions"`
}

type ServiceDiff

ServiceDiff is the typed Before/After payload when an actions.Diff describes an os.systemd mutation. Spec-66 wave 4 targets os.systemd only; the legacy os.service handler keeps its package-local payload until a later wave migrates it. Consumers dispatch on the typed After value (not on Resource.Kind, which is shared with os.service via ResourceService).

type ServiceDiff struct {
    // Name is the unit filename with suffix (e.g. "myapp.service").
    Name string `json:"name,omitempty"`

    // State is the desired terminal state: "present" or "absent".
    State string `json:"state,omitempty"`

    // Scope is the systemd bus: "system" (default) or "user". user
    // scope writes to ~/.config/systemd/user and routes systemctl
    // calls through --user (no sudo).
    Scope string `json:"scope,omitempty"`

    // Path is the unit directory (default /etc/systemd/system, or
    // ~/.config/systemd/user when Scope=user).
    Path string `json:"path,omitempty"`

    // Sections lists the unit-file sections this step populates
    // (Unit, Service, Timer, Socket, Install). Lets consumers see
    // the shape of the unit without surfacing potentially large /
    // sensitive Exec lines themselves.
    Sections []string `json:"sections,omitempty"`

    // Enabled / Started mirror the desired lifecycle flags. nil
    // means "leave alone."
    Enabled *bool `json:"enabled,omitempty"`
    Started *bool `json:"started,omitempty"`
}

type SysctlDiff

SysctlDiff is the typed Before/After payload when an actions.Diff describes an os.sysctl mutation. Intent-only — no /proc/sys read at plan time. Consumers dispatch on Resource.Attributes["kind"] == "os.sysctl".

type SysctlDiff struct {
    // Name is the sysctl key (e.g. "net.ipv4.ip_forward"; identity).
    Name string `json:"name,omitempty"`

    // State is "present" or "absent". Empty defaults to "present".
    State string `json:"state,omitempty"`

    // Value is the desired value, stringified. Empty when
    // state=absent.
    Value string `json:"value,omitempty"`
}

type TransactionDiff

TransactionDiff is the synthesized After payload for a transaction- parent step. Transactions have no handler, so the planner cannot emit a Differ.Diff — instead the plan-render call site synthesizes one from the parent step + its sibling children. Consumers dispatch on Resource.Attributes["kind"] == "transaction".

type TransactionDiff struct {
    // Name is the transaction step's name (Identifier carries the
    // same value).
    Name string `json:"name,omitempty"`

    // AllowIrreversible mirrors step.AllowIrreversible — when true,
    // non-Reverser children were accepted at plan time.
    AllowIrreversible bool `json:"allow_irreversible,omitempty"`

    // BodyChildren is the ordered list of body-child step names (the
    // `transaction:` block).
    BodyChildren []string `json:"body_children,omitempty"`

    // RollbackChildren is the ordered list of rollback-child step
    // names (the `on_rollback:` block).
    RollbackChildren []string `json:"rollback_children,omitempty"`
}

type TryDiff

TryDiff is the synthesized After payload for a try-parent step. Same shape rationale as TransactionDiff. Consumers dispatch on Resource.Attributes["kind"] == "try".

type TryDiff struct {
    // Name is the try step's name.
    Name string `json:"name,omitempty"`

    // TryChildren is the ordered list of body-child step names (the
    // `try:` block).
    TryChildren []string `json:"try_children,omitempty"`

    // CatchChildren is the ordered list of catch-handler step names
    // (the `catch:` block).
    CatchChildren []string `json:"catch_children,omitempty"`

    // FinallyChildren is the ordered list of finally-handler step
    // names (the `finally:` block).
    FinallyChildren []string `json:"finally_children,omitempty"`
}

type UserDiff

UserDiff is the typed Before/After payload when an actions.Diff describes an os.user mutation. The handler emits intent only (no getent probe at plan time); Before stays nil. Resource.Kind is ResourceOther on the wire today (no dedicated kind); consumers dispatch on Resource.Attributes["kind"] == "os.user".

type UserDiff struct {
    // Name is the account name (idempotency identity).
    Name string `json:"name,omitempty"`

    // State is the desired terminal state: "present" or "absent".
    // Empty maps to "present" by handler convention.
    State string `json:"state,omitempty"`

    // UID, when set, is the requested numeric uid.
    UID *int `json:"uid,omitempty"`

    // Shell is the requested login shell.
    Shell string `json:"shell,omitempty"`

    // Home is the requested home directory path.
    Home string `json:"home,omitempty"`

    // Groups is the supplementary group list. The primary group, if
    // set on the step, is folded in as the first entry so consumers
    // don't need two code paths to enumerate "all groups this user
    // will be in."
    Groups []string `json:"groups,omitempty"`

    // System mirrors the system-account flag.
    System bool `json:"system,omitempty"`
}

Generated by gomarkdoc