verb

package
v0.23.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 3, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

Documentation

Overview

Package verb — I2.5 allow-rule composition.

Allow gates an agent's verb invocation against the union of currently-active scopes attached to the agent. Per docs/pocv3/design/provenance-model.md §"Composition with entity FSMs":

allow(verb v on entity e by actor a) =
    legalEntityTransition(e, v.target_state)         // existing entity FSM
    AND scopeAllows(a, v, e)                          // new scope check

For human/... actors with no --principal flag, scopeAllows is skipped entirely — humans need no delegation. For ai/... or other non-human actors, at least one active scope must answer "yes" to scopeAllows; if none does, the verb refuses with provenance-no- active-scope and no commit lands.

The function is intentionally pure: tree (forward-reachability) and pre-loaded scopes are passed in. The cmd dispatcher does the git I/O (loadActiveScopesForActor) and tree.Load.

Package verb — I2.5 audit-only recovery mode (G24).

When a mutating verb fails partway through (e.g., `.git/index.lock` contention) and the operator finishes the work with a plain `git commit`, the framework currently goes silent — `aiwf history` filters the manual commit out and the audit trail has an unsignalled hole. The audit-only mode is the recovery path:

aiwf cancel  <id>  --audit-only --reason "<text>"
aiwf promote <id> <state> --audit-only --reason "<text>"
aiwf promote <composite-id> --phase <p> --audit-only --reason "<text>"

Each mode produces an empty-diff commit carrying the standard trailer block plus `aiwf-audit-only: <reason>` so the commit is distinguishable from a normal verb commit at read time. The verb refuses unless the entity is *already* at the named target state — audit-only records what's already true; it never makes a transition (that's --force's job; the two are mutually exclusive per coherence.go).

Reference: docs/pocv3/plans/provenance-model-plan.md §"Step 5b" and docs/pocv3/gaps.md G24.

Package verb — I2.5 trailer-coherence rules.

Package verb implements aiwf's mutating verbs: add, promote, cancel, rename, reallocate.

Every verb is *validate-then-write* per docs/pocv3/design/design-decisions.md: the verb computes the projected new tree in memory, runs the check.Run validators against the projection, and returns either findings (no disk writes occurred) or a Plan (file ops + commit metadata). The orchestrator in cmd/aiwf applies the plan only when findings are clean. There is no rollback path because nothing is written until the projection is known good.

Index

Constants

View Source
const (
	CoherenceRuleOnBehalfOfMissingAuthorizedBy    = "on-behalf-of-missing-authorized-by"
	CoherenceRuleAuthorizedByMissingOnBehalfOf    = "authorized-by-missing-on-behalf-of"
	CoherenceRulePrincipalMissingForNonHumanActor = "principal-missing-for-non-human-actor"
	CoherenceRulePrincipalForbiddenForHumanActor  = "principal-forbidden-for-human-actor"
	CoherenceRuleOnBehalfOfForbiddenForHumanActor = "on-behalf-of-forbidden-for-human-actor"
	CoherenceRuleForceWithOnBehalfOf              = "force-with-on-behalf-of"
	CoherenceRuleForceNonHuman                    = "force-non-human"
	CoherenceRuleAuditOnlyWithForce               = "audit-only-with-force"
	CoherenceRuleAuditOnlyNonHuman                = "audit-only-non-human"
)

Coherence rule names. These are referenced by `aiwf check`'s provenance findings (step 7) — keep them stable.

View Source
const (
	OnCollisionFail   = "fail"
	OnCollisionSkip   = "skip"
	OnCollisionUpdate = "update"
)

On-collision modes for the Import verb.

Variables

View Source
var CodeAuthorizeKindNotAllowed = codes.Code{ID: "authorize-kind-not-allowed", Class: codes.ClassLegality}

CodeAuthorizeKindNotAllowed is the typed kernel-code descriptor carried by AuthorizeKindError when aiwf authorize refuses a scope-entity that is not an epic or milestone (D-0007). It declares codes.ClassLegality, the marker the closed legality set is enumerated from (D-0011). Consumers see its codes.Code.ID string via AuthorizeKindError.Code and in the message text.

View Source
var CodeEpicCancelNonTerminalChildren = codes.Code{ID: "epic-cancel-non-terminal-children", Class: codes.ClassLegality}

CodeEpicCancelNonTerminalChildren is the typed kernel-code descriptor carried by EpicCancelNonTerminalChildrenError when `aiwf cancel` refuses an epic that still owns one or more non-terminal child milestones (D-0003). It declares codes.ClassLegality, the marker the closed legality set is enumerated from (D-0011). Consumers see its codes.Code.ID string via EpicCancelNonTerminalChildrenError.Code and in the message text.

View Source
var CodeMilestoneCancelNonTerminalACs = codes.Code{ID: "milestone-cancel-non-terminal-acs", Class: codes.ClassLegality}

CodeMilestoneCancelNonTerminalACs is the typed kernel-code descriptor carried by MilestoneCancelNonTerminalACsError when `aiwf cancel` refuses a milestone that still carries one or more `open` acceptance criteria (D-0004). It declares codes.ClassLegality (D-0011). Consumers see its codes.Code.ID string via MilestoneCancelNonTerminalACsError.Code and in the message text.

View Source
var CodePreflightBranchContextRequired = codes.Code{ID: "branch-context-required", Class: codes.ClassLegality}

CodePreflightBranchContextRequired is the typed kernel-code descriptor carried by PreflightBranchContextRequiredError when aiwf authorize refuses opening a scope on an ai/* agent because neither --branch was passed nor the current checkout matches a ritual shape (M-0103 / ADR-0010). Class is codes.ClassLegality.

View Source
var CodePreflightBranchNotFound = codes.Code{ID: "branch-not-found", Class: codes.ClassLegality}

CodePreflightBranchNotFound is the typed kernel-code descriptor carried by PreflightBranchNotFoundError when aiwf authorize refuses opening a scope on an ai/* agent because --branch <name> was passed but no local branch by that name exists (M-0103 / ADR-0010). Class is codes.ClassLegality.

View Source
var CodePreflightRungPair = codes.Code{ID: "rung-pair-illegal", Class: codes.ClassLegality}

CodePreflightRungPair is the typed kernel-code descriptor carried by PreflightRungPairError when aiwf authorize refuses opening a scope on an ai/* agent because the (CurrentBranch rung, --branch rung) pair is not in the legal set {(trunk, epic), (epic, milestone), (milestone, patch), (epic, patch)} per ADR-0010 (M-0161/AC-2 / G-0201). Class is codes.ClassLegality.

Functions

func Apply

func Apply(ctx context.Context, root string, p *Plan) (err error)

Apply executes a verb's Plan against the consumer repo at root: it runs every OpMove via `git mv`, every OpWrite atomically to disk via pathutil.AtomicWriteFile (creating parent directories as needed), stages the writes with `git add`, then creates the single commit with the plan's subject and trailers.

Moves run before writes so that when a verb (notably reallocate) renames a file/dir and also rewrites files inside that dir, the writes land at the new locations.

Atomicity: Apply is all-or-nothing. If any step after the first mutation fails (write error, commit failure, panic), the worktree and index are restored to their pre-Apply state via a deferred rollback. The repo ends up exactly as if Apply had never been called. This preserves the framework's "every mutating verb produces exactly one git commit" guarantee under partial failure.

G34 isolation: the verb's commit must capture exactly the verb's mutation (plus whatever pre-commit hooks add — notably the aiwf STATUS.md regenerator), and nothing of the user's pre-existing staged work. Apply enforces this in two halves:

  1. Conflict guard. If the user has staged a path the verb is about to write, refuse before any disk mutation — the two intents disagree on what to commit, and the kernel will not silently pick one. Error names the conflicting path and points at `git restore --staged` / `git stash`.

  2. Stash isolation. If the user has staged anything else, those entries are pushed onto the stash for the duration of the commit and popped after. The verb runs against a clean index, hooks fire normally (their `git add` lands in the verb's commit), and the user's staged work is restored after.

func CheckTrailerCoherence

func CheckTrailerCoherence(trailers []gitops.Trailer) error

CheckTrailerCoherence validates the I2.5 required-together / mutually-exclusive trailer rules on an assembled trailer set. Returns nil when the set is coherent; returns a *CoherenceError naming a single rule violation otherwise.

The check intentionally returns the FIRST violation encountered — surfacing all of them at once would force callers to display a list when typically one fix unblocks the rest. Standing-rule callers (aiwf check) re-run per commit so each commit's first violation surfaces.

Per provenance-model.md §"Required-together and mutually-exclusive rules":

  • on-behalf-of ↔ authorized-by: both present or both absent.
  • principal ↔ non-human actor: required-together; principal is forbidden for a human actor.
  • on-behalf-of: forbidden for a human actor (direct human acts have no on-behalf-of).
  • force + on-behalf-of: mutually exclusive (force is human-only; on-behalf-of implies an agent operator).
  • force + non-human actor: forbidden (force is sovereign, human- only).
  • audit-only + force: mutually exclusive (force makes a transition; audit-only records one that already happened — distinct intents).
  • audit-only + non-human actor: forbidden (audit-only is sovereign, same rationale as force).

The (authorize, on-behalf-of) sub-agent-delegation pair is deliberately NOT enforced — that policy decision is reserved for G22 per the design doc.

Types

type AddOptions

type AddOptions struct {
	// Milestone: id of the parent epic. Required.
	EpicID string
	// Milestone: TDD policy declaration. Required for kind=milestone;
	// must be one of "required" / "advisory" / "none". Empty on
	// non-milestone kinds (validated by validateAddOptsForKind).
	// Closes G-055 layer #1 — pre-fix, milestones could be created
	// with the field absent and the kernel silently treated absence
	// as `tdd: none`. Post-fix, the policy decision is a single
	// explicit act recorded in the create commit.
	TDD string
	// Milestone: optional list of milestone ids the new milestone
	// depends on. Each id must resolve to an existing milestone
	// (allocation-time referent validation, M-076/AC-4); the list is
	// written verbatim into the entity's depends_on frontmatter array.
	// Cycle detection stays in `aiwf check`. Empty list (or absence)
	// produces no depends_on block.
	DependsOn []string
	// Gap: optional reference to the milestone or epic where the gap
	// was discovered.
	DiscoveredIn string
	// Decision: optional list of entity ids the decision relates to.
	RelatesTo []string
	// Contract: optional list of ADR ids motivating the contract.
	LinkedADRs []string
	// Contract: when all three of BindValidator/BindSchema/BindFixtures
	// are non-empty, Add atomically appends the binding to
	// aiwf.yaml.contracts.entries[] in the same commit. Partial
	// triplets are an error; the verb is all-or-nothing on the bind.
	BindValidator string
	BindSchema    string
	BindFixtures  string
	// Contract: when atomic-bind is requested, AiwfDoc must be the
	// editable aiwf.yaml document and AiwfContracts the parsed
	// contracts: block (nil ok if absent). The CLI dispatcher loads
	// these only when the bind flags are present.
	AiwfDoc       *aiwfyaml.Doc
	AiwfContracts *aiwfyaml.Contracts
	// Contract: repo root, needed to verify that the bound schema and
	// fixtures paths exist on disk (G18). Required when the bind
	// triplet is provided; ignored otherwise.
	RepoRoot string
	// BodyOverride, when non-nil, replaces the kind's default body
	// template. Used by `aiwf add --body-file` so the body content
	// rides along with the create commit instead of forcing a
	// follow-up untrailered hand-edit (M-056). Nil leaves current
	// behavior — the per-kind template lands as the body. The bytes
	// must not begin with a YAML frontmatter delimiter (`---\n`);
	// callers pass body content only, not a full markdown document.
	BodyOverride []byte
	// TitleMaxLength caps the length of --title. The CLI dispatcher
	// sets it from `aiwf.yaml`'s `entities.title_max_length` (default
	// 80 per G-0102). Zero or negative means "uncapped" — used by
	// tests that don't thread a config and don't care about cap
	// policy. Verbs reject titles over this length so the on-disk
	// slug, frontmatter title, and rendered surfaces stay in sync.
	TitleMaxLength int
	// Area is the optional workstream grouping tag (E-0043, M-0173)
	// for the five root kinds (epic, ADR, gap, decision, contract).
	// A milestone derives its area from its parent epic and never
	// stores one, so a non-empty Area on kind=milestone is rejected by
	// validateAddOptsForKind. The CLI dispatcher validates the value
	// against `aiwf.yaml: areas.members` (the M-0171 accessor) before
	// setting it — the verb-time twin of the M-0172 area-unknown check
	// — and, for a gap with --discovered-in and no explicit --area,
	// derives it from the discovered-in entity's effective area.
	Area string
}

AddOptions carries the per-kind extra arguments to Add. Only the fields relevant to the kind are read; others are ignored.

type AllowInput

type AllowInput struct {
	Kind         VerbKind
	TargetID     string
	CreationRefs []string
	MoveSource   string
	Actor        string
	Principal    string
	Scopes       []*scope.Scope
	Tree         *tree.Tree
}

AllowInput bundles every input the allow-rule consumes. Lets the cmd dispatcher build a single struct rather than passing seven arguments through a chain of helpers.

Kind picks the reachability variant. TargetID is the entity the verb mutates (or the destination, for VerbMove). For VerbCreate, CreationRefs lists the outbound reference targets the new entity would carry; for VerbMove, MoveSource is the original location.

Actor is the operator (whoever ran the verb). Principal is the human on whose behalf the operator is acting (always human/...); empty when the actor is acting directly.

Scopes is the union of active scopes attached to Actor — the cmd dispatcher loads it via loadActiveScopesForActor (filters authorize commits by aiwf-to: <Actor>).

Tree is the in-memory entity tree used for forward reachability.

type AllowResult

type AllowResult struct {
	Allowed bool
	Scope   *scope.Scope
	Reason  string
	// Err is the denial error when Allowed is false (nil when allowed).
	// For the scope-reachability and no-active-scope denials it is a
	// Coded error (entity.Code-extractable: *ScopeOutOfReachError /
	// *NoActiveScopeError); the pre-scope usage denials carry a plain
	// error. The cmd dispatcher returns it (wrapped with %w) so a Coded
	// refusal surfaces as error.code in the --format=json envelope.
	// Invariant: every Allowed==false return sets Err.
	Err error
}

AllowResult carries the verdict. When Allowed is true and Scope is non-nil, the cmd dispatcher decorates the verb's plan with aiwf-on-behalf-of: <Scope.Principal> and aiwf-authorized-by: <Scope.AuthSHA>. When Allowed is true and Scope is nil, the actor is human/... and no scope decoration is needed (direct human act).

Reason carries a one-line explanation when Allowed is false; the cmd dispatcher surfaces it as the user-facing error.

func Allow

func Allow(in AllowInput) AllowResult

Allow runs the I2.5 allow-rule over the given inputs. Pure: no I/O, no git access. Returns a verdict the cmd dispatcher acts on.

Decision tree:

  • Empty actor → denied (kernel never lets an unidentified operator commit; the cmd dispatcher should have refused earlier, but defensive). Reason: "actor is required".

  • human/... actor: -> Principal must be empty (humans act directly; principal forbidden per the trailer-coherence rules). When non-empty, denied with "principal forbidden for human actor". -> Otherwise: Allowed = true, Scope = nil.

  • non-human actor (ai/... / bot/...): -> Principal must be set (every agent needs a human accountor; enforced again by trailer coherence). When empty, denied with "principal required for non-human actor". -> Iterate Scopes (the union of active scopes attached to this actor). Pick the most-recently-opened that satisfies scopeAllows for the given (Kind, TargetID). On match: Allowed = true, Scope = the matching scope. On no match the denial is split (D-0014): if at least one active scope existed but none reached the target, Err is a *ScopeOutOfReachError (provenance-authorization-out-of-scope); if there was no active scope at all, Err is a *NoActiveScopeError (provenance-no-active-scope).

Per the design's "if multiple match, pick the most-recently-opened deterministically" rule, scopes are walked in reverse insertion order so the latest open wins.

type AuthorizeKindError added in v0.9.0

type AuthorizeKindError struct {
	Kind entity.Kind
}

AuthorizeKindError reports an aiwf authorize refused because the scope-entity is not an epic or milestone (D-0007). It implements entity.Coded, carrying CodeAuthorizeKindNotAllowed. Error preserves the established message text (including the code) so message-matching consumers keep working while machine consumers use Code.

func (*AuthorizeKindError) Code added in v0.9.0

func (e *AuthorizeKindError) Code() string

Code returns CodeAuthorizeKindNotAllowed's ID, satisfying entity.Coded.

func (*AuthorizeKindError) Error added in v0.9.0

func (e *AuthorizeKindError) Error() string

Error implements error.

type AuthorizeMode

type AuthorizeMode int

AuthorizeMode picks one of the three sub-verbs of `aiwf authorize`. Each mode produces exactly one commit; mixing modes is a usage error caught by the cmd dispatcher before this package sees the call.

const (
	// AuthorizeOpen opens a fresh scope on the named entity, granting
	// the agent identified by AuthorizeOptions.Agent. Refused when the
	// entity is at a terminal status, unless overridden with Force +
	// non-empty Reason.
	AuthorizeOpen AuthorizeMode = iota
	// AuthorizePause pauses the most-recently-opened active scope on
	// the named entity. Reason is required (non-empty after trim).
	AuthorizePause
	// AuthorizeResume resumes the most-recently-paused scope on the
	// named entity. Reason is required (non-empty after trim).
	AuthorizeResume
)

Authorize sub-verbs.

type AuthorizeOptions

type AuthorizeOptions struct {
	Mode AuthorizeMode
	// Agent is the role/<id> the scope authorizes (e.g. "ai/claude").
	Agent string
	// Reason is the rationale; required for pause/resume, optional for
	// open (required when Force is set).
	Reason string
	// Branch (M-0102 / ADR-0010) is the ritual branch a scope is bound
	// to. Optional: when empty *and* the target agent is non-ai/*, no
	// aiwf-branch: trailer is emitted (backward-compatible). For ai/*
	// targets, the M-0103 preflight resolves an empty Branch from
	// CurrentBranch when the current checkout is a ritual shape, and
	// refuses with PreflightBranchContextRequiredError when it is not.
	// Validated against the git-ref shape rule in gitops.ValidateTrailer
	// at trailer-assembly time.
	Branch string
	// CurrentBranch (M-0103) is the short name of the consumer's
	// currently-checked-out branch at verb-call time, as the CLI layer
	// resolves it via `git symbolic-ref --short HEAD`. Empty when HEAD
	// is detached, when the CLI's git invocation fails, or when this
	// package is exercised by a verb-level test that does not set it.
	// Read by the M-0103 preflight only when the target agent is ai/*
	// and --force is not set; in every other code path the field is
	// ignored, so leaving it empty in unrelated tests is harmless.
	CurrentBranch string
	// BranchExists (M-0103) reports whether Branch refers to a local
	// branch that resolves under refs/heads/<Branch>, as the CLI layer
	// determines via `git show-ref --verify`. The CLI sets this iff
	// Branch is non-empty (when Branch is empty there is nothing to
	// check). Read by the M-0103 preflight only when the target agent
	// is ai/* and --force is not set; in every other code path the
	// field is ignored.
	BranchExists bool
	// BranchSHA (M-0161/AC-6, G-0206) is the bound branch's tip
	// SHA at scope-open time, populated by the CLI layer when
	// Branch exists locally (via `git rev-parse <branch>`). Empty
	// when the branch doesn't yet exist (future-branch carve-out
	// per M-0103 / M-0105) or when the CLI's git invocation
	// fails. The verb emits `aiwf-branch-sha:` iff non-empty,
	// keeping the trailer absent for the future-branch case so
	// the rule falls back to name-only resolution there. The
	// value is validated against the canonical 40-char lowercase
	// hex shape by validateAuthorizeTrailers below (gitops
	// shape rule for TrailerBranchSHA).
	BranchSHA string
	// TrunkShort (M-0161/AC-1, G-0200) is the consumer's configured
	// trunk short-name as derived from `aiwf.yaml.allocate.trunk` via
	// `Config.TrunkBranchShortName()`. Used by the AI-target
	// preflight's "trunk + ritual --branch" carve-out so the predicate
	// honors the operator's configured trunk rather than the literal
	// `"main"`. Populated by the CLI layer via
	// `cliutil.ConfiguredTrunkBranchShortName`; empty (e.g., from a
	// verb-level test that doesn't set it) is treated as "no
	// resolvable trunk; do not match" — the carve-out's left arm
	// fails and preflight falls through to the implicit-ritual-
	// current path.
	TrunkShort string
	Force      bool
	Scopes     []*scope.Scope
}

AuthorizeOptions configures one invocation of the authorize verb.

Scopes carries every scope ever opened on the target entity, in open-order (oldest first), with each scope's current State derived from the entity's commit history. The cmd dispatcher loads it via loadEntityScopes; this package never reads git directly. For AuthorizeOpen the slice is unused (a fresh scope doesn't depend on existing ones); for AuthorizePause / AuthorizeResume it is the source of truth for the most-recently-opened-active / most-recently-paused selection.

type CoherenceError

type CoherenceError struct {
	Rule    string
	Message string
}

CoherenceError is the typed error CheckTrailerCoherence returns when a trailer set violates one of the I2.5 required-together or mutually- exclusive rules. Rule names a single canonical violation per error so the caller (verb refusal path or aiwf check standing rule) can map it to a finding code without parsing prose.

The Rule strings are the load-bearing identifiers: do not change one without updating the corresponding `aiwf check` standing-rule subcode in internal/check/provenance.go (added in step 7).

func AsCoherenceError

func AsCoherenceError(err error) (ce *CoherenceError, rule string)

AsCoherenceError returns the *CoherenceError if err is one (or wraps one), and the rule name; otherwise returns nil and "". Helper for callers (notably the standing rule in step 7) that switch on rule names rather than message text.

func (*CoherenceError) Error

func (e *CoherenceError) Error() string

type ContractBindOptions

type ContractBindOptions struct {
	Validator string
	Schema    string
	Fixtures  string
	Force     bool
}

ContractBindOptions carries the bind-time arguments. All three path/name fields are required; Force is the escape hatch for the "binding already exists with different values" guard.

type EpicCancelNonTerminalChildrenError added in v0.9.0

type EpicCancelNonTerminalChildrenError struct {
	// Epic is the id of the epic whose cancel was refused.
	Epic string
	// Children holds the sorted ids of the offending non-terminal child
	// milestones.
	Children []string
}

EpicCancelNonTerminalChildrenError reports that `aiwf cancel` refused an epic because one or more of its child milestones are still non-terminal (D-0003: refuse-with-listing, no auto-cascade). The operator must dispose each listed milestone (cancel or done) before the epic can be cancelled. It implements entity.Coded, carrying CodeEpicCancelNonTerminalChildren.

func (*EpicCancelNonTerminalChildrenError) Code added in v0.9.0

Code returns CodeEpicCancelNonTerminalChildren's ID, satisfying entity.Coded.

func (*EpicCancelNonTerminalChildrenError) Error added in v0.9.0

Error implements error. It names the epic, the count of offending milestones, the sorted ids, instructs the operator to dispose each first, and includes CodeEpicCancelNonTerminalChildren.ID so message-matching consumers can recognize the refusal.

type FileOp

type FileOp struct {
	Type    OpType
	Path    string // source path (relative to repo root)
	NewPath string // destination path (only for OpMove)
	Content []byte // file contents (only for OpWrite)
}

FileOp is a single planned filesystem mutation.

type ImportOptions

type ImportOptions struct {
	// OnCollision selects behavior when a manifest entry has an
	// explicit id that already exists in the tree. Empty defaults
	// to "fail".
	OnCollision string
	// TitleMaxLength caps each manifest entry's title length. The
	// CLI dispatcher sets it from `aiwf.yaml`'s
	// `entities.title_max_length` (default 80 per G-0102). Zero or
	// negative means "uncapped" — used by tests that don't thread a
	// config. An import is rejected atomically if any entry's title
	// exceeds the cap; the kernel's "one verb = one commit" rule
	// makes per-entry partial-acceptance untenable.
	TitleMaxLength int
}

ImportOptions controls how Import handles edge cases.

type ImportResult

type ImportResult struct {
	Findings []check.Finding
	Plans    []*Plan
}

ImportResult is what Import returns. Either Findings is non-empty (validation rejected the projection; nothing should be applied) or Plans is non-empty (one plan in single-commit mode, N plans in per-entity mode; the orchestrator applies each in order).

func Import

func Import(ctx context.Context, t *tree.Tree, m *manifest.Manifest, actor string, opts ImportOptions) (*ImportResult, error)

Import processes a manifest against the existing tree and returns either findings (validation failed) or plans (validation passed, orchestrator should apply). Pure with respect to the filesystem; no writes happen here.

The processing pipeline is:

  1. resolve --on-collision against entries with explicit ids that already exist in the tree (fail/skip/update).
  2. detect intra-manifest duplicate explicit ids (always an error).
  3. allocate auto-id entries from max(existing ∪ reserved) + 1.
  4. resolve paths for every entry (new or updating an existing one).
  5. project the tree (add or replace per entry) with PlannedFiles populated for every OpWrite path.
  6. run projectionFindings; abort with findings if any error level issues are introduced.
  7. assemble plans according to the manifest's commit mode.

type MilestoneCancelNonTerminalACsError added in v0.9.0

type MilestoneCancelNonTerminalACsError struct {
	// Milestone is the id of the milestone whose cancel was refused.
	Milestone string
	// ACs holds the composite ids (`M-NNNN/AC-N`) of the offending open
	// acceptance criteria.
	ACs []string
}

MilestoneCancelNonTerminalACsError reports that `aiwf cancel` refused a milestone because one or more of its acceptance criteria are still `open` (D-0004: refuse-with-listing, no auto-cascade). The operator must dispose each listed AC (met, deferred, or cancelled) before the milestone can be cancelled. It implements entity.Coded, carrying CodeMilestoneCancelNonTerminalACs.

func (*MilestoneCancelNonTerminalACsError) Code added in v0.9.0

Code returns CodeMilestoneCancelNonTerminalACs's ID, satisfying entity.Coded.

func (*MilestoneCancelNonTerminalACsError) Error added in v0.9.0

Error implements error. It names the milestone, the count of offending ACs, the composite ids, instructs the operator to dispose each first, and includes CodeMilestoneCancelNonTerminalACs.ID so message-matching consumers can recognize the refusal.

type NoActiveScopeError added in v0.9.0

type NoActiveScopeError struct {
	// Actor is the non-human operator whose act was refused.
	Actor string
}

NoActiveScopeError reports that a non-human actor attempted a verb with no active scope at all — distinct from out-of-reach, where a scope exists but does not contain the target. It implements entity.Coded, carrying check.CodeProvenanceNoActiveScope.

func (*NoActiveScopeError) Code added in v0.9.0

func (e *NoActiveScopeError) Code() string

Code returns check.CodeProvenanceNoActiveScope, satisfying entity.Coded.

func (*NoActiveScopeError) Error added in v0.9.0

func (e *NoActiveScopeError) Error() string

Error implements error.

type OpType

type OpType int

OpType discriminates between file operations.

The set is deliberately closed to OpWrite and OpMove — there is no OpDelete, by design, not omission. aiwf never deletes an entity file: "removal" is a status flip to a terminal value (cancelled / wontfix / rejected / retired) followed by an archive sweep that OpMoves the file into its per-kind archive/ subdirectory (ADR-0004). A verb that needs to "remove" something promotes it to a terminal status and lets `aiwf archive` relocate it; nothing in the kernel unlinks a tracked entity.

const (
	// OpWrite creates or overwrites a regular file at Path with
	// Content. Parent directories are created as needed.
	OpWrite OpType = iota
	// OpMove relocates Path to NewPath via `git mv`. Used by rename
	// and reallocate. The source must already be tracked by git.
	OpMove
)

type Plan

type Plan struct {
	Subject    string
	Body       string
	Trailers   []gitops.Trailer
	Ops        []FileOp
	AllowEmpty bool
}

Plan describes the work the orchestrator must do after validation passes: a set of file operations to apply on disk, plus the commit subject, optional body, and trailers to record once they're staged.

Body is free-form prose: typically the human-supplied --reason for a status transition. Empty when the verb has no narrative to record. Stored in the commit body (between subject and trailers), surfaced by `aiwf history` for events that carry one.

AllowEmpty signals that the plan's commit has no file-level diff and must be created via `git commit --allow-empty`. Used by `aiwf authorize` (which records a scope event in trailers without touching any entity file) and the `--audit-only` recovery mode added in plan step 5b. The default (false) is the normal verb behaviour: a commit without staged changes errors.

type PreflightBranchContextRequiredError added in v0.12.0

type PreflightBranchContextRequiredError struct {
	// Agent is the --to ai/<id> value that triggered the preflight.
	Agent string
	// CurrentBranch is whatever the caller reported as the current
	// checkout (empty when HEAD is detached or git failed); included
	// in the message so the operator can see what was checked.
	CurrentBranch string
}

PreflightBranchContextRequiredError reports an aiwf authorize refused because the AI-target preflight (M-0103) found no ritual branch context in play — neither was --branch supplied, nor does the current checkout match a ritual shape recognized by internal/branchparse/. Implements entity.Coded via Code; carries CodePreflightBranchContextRequired.

func (*PreflightBranchContextRequiredError) Code added in v0.12.0

Code returns CodePreflightBranchContextRequired's ID, satisfying entity.Coded.

func (*PreflightBranchContextRequiredError) Error added in v0.12.0

Error implements error.

M-0161/AC-7: when CurrentBranch is empty (the CLI reports empty on detached HEAD or when git fails), the message names "detached HEAD has no ritual context" explicitly so the operator sees the exact state rather than just "current checkout ” does not match". The override path (--force --reason) and the ritual-branch landing options are listed the same way for both cases.

type PreflightBranchNotFoundError added in v0.12.0

type PreflightBranchNotFoundError struct {
	// Branch is the --branch value that did not resolve under refs/heads/.
	Branch string
}

PreflightBranchNotFoundError reports an aiwf authorize refused because the AI-target preflight (M-0103) was given --branch <name> but the named branch does not exist locally. Implements entity.Coded via Code; carries CodePreflightBranchNotFound.

func (*PreflightBranchNotFoundError) Code added in v0.12.0

Code returns CodePreflightBranchNotFound's ID, satisfying entity.Coded.

func (*PreflightBranchNotFoundError) Error added in v0.12.0

Error implements error.

type PreflightRungPairError added in v0.12.0

type PreflightRungPairError struct {
	// CurrentBranch is the operator's current checkout.
	CurrentBranch string
	// CurrentRung is the rung CurrentBranch classified as
	// (one of "trunk"/"epic"/"milestone"/"patch"/"").
	CurrentRung string
	// TargetBranch is the --branch value that was rejected.
	TargetBranch string
	// TargetRung is the rung TargetBranch classified as.
	TargetRung string
}

PreflightRungPairError reports an aiwf authorize refused because the (CurrentBranch rung, --branch rung) pair is not in the legal set per ADR-0010 (M-0161/AC-2). Carries the offending branches AND their classified rungs so the operator can see exactly which pair was rejected. Implements entity.Coded via Code; carries CodePreflightRungPair.

func (*PreflightRungPairError) Code added in v0.12.0

func (e *PreflightRungPairError) Code() string

Code returns CodePreflightRungPair's ID, satisfying entity.Coded.

func (*PreflightRungPairError) Error added in v0.12.0

func (e *PreflightRungPairError) Error() string

Error implements error.

type PromoteOptions

type PromoteOptions struct {
	AddressedBy       []string
	AddressedByCommit []string
	SupersededBy      string
}

PromoteOptions carries optional fields for Promote — resolver pointers (gap.addressed_by / gap.addressed_by_commit, adr.superseded_by) that need to be written atomically with the status change so the matching check rule (gap-addressed-has-resolver, adr-supersession-mutual) is satisfied without a follow-up hand-edit.

AddressedBy / AddressedByCommit are valid only when the target is a gap and newStatus is "addressed". SupersededBy is valid only when the target is an ADR and newStatus is "superseded". Mismatches return a Go error before any disk work — usage misalignment, not a finding.

When a slice or string is set, it replaces the existing field on the entity (this is a one-shot setting at status-change time, not a merge). Unset fields leave the entity's existing values untouched.

type RecipeInstallOptions

type RecipeInstallOptions struct {
	Force bool
}

RecipeInstallOptions carries the recipe-install arguments shared between embedded-name and --from-path entry points. Force allows replacing a validator that already exists with a different shape.

type Result

type Result struct {
	Findings    []check.Finding
	Plan        *Plan
	NoOp        bool
	NoOpMessage string
}

Result is what every verb returns. Exactly one of Findings, Plan, or the NoOp signal is populated:

  • Findings non-empty → validation failed; no disk changes pending. Caller renders findings and exits 1.
  • Plan non-nil → projection is clean; caller should apply Operations, stage them, and commit with the plan's subject + trailers.
  • NoOp == true → validation passed, but the requested change is already in place. Caller prints NoOpMessage on stdout and exits 0. Used by idempotent verbs (bind on exact match, etc.).

func AcknowledgeIllegal added in v0.9.0

func AcknowledgeIllegal(ctx context.Context, root, sha, forEntity, actor, reason string) (*Result, error)

AcknowledgeIllegal records a retroactive sovereign override for a historical commit that one of the kernel's audit rules flags. The acknowledgment lives as a current-day empty commit carrying:

aiwf-verb: acknowledge-illegal
aiwf-force-for: <sha>
aiwf-actor: human/<name>
aiwf-reason: <free-form text>
aiwf-entity: <id>       (only when forEntity is non-empty)

The fsm-history-consistent rule (M-0136/AC-2) walks HEAD's reachable history for `aiwf-force-for` trailers and exempts illegal-transition findings whose offending commit appears as a target. Six other rules consume the same SHA set via the M-0159/AC-3 lift.

G-0231 item 3: a SEVENTH consumer rule — `provenance-untrailered-entity-commit` — was added with TIGHTER scope. Its findings are per-(commit, entity) pairs, so it requires per-(SHA, entity) acks: the ack commit must carry BOTH `aiwf-force- for: <sha>` AND `aiwf-entity: <id>`, and the verb verifies at write time that <sha>'s diff actually touches <id>'s file. The kernel-integrity property this adds: even if the operator (human or LLM) writes the wrong entity id with the right SHA, the verb refuses before the ack lands. SHA existence was already verified pre-G-0231 by shaAckable; entity binding is the new check.

Constraints (M-0136/AC-1, extended by G-0231 item 3):

  • reason must be non-empty after trim (sovereign acts require a written rationale).
  • actor must be `human/...` (sovereign acts trace to a named human; no LLM / bot ack).
  • sha must match the 7-40-hex SHA pattern (the trailer's value constraint, enforced via gitops.ValidateTrailer).
  • forEntity is OPTIONAL. When empty, the ack is per-SHA blanket (the legacy shape covering the first six rules). When set, the verb verifies <sha>'s diff touches <id>'s file and emits `aiwf-entity: <id>` in the ack commit (the per-(SHA, entity) shape required by provenance-untrailered-entity-commit).

M-0136/AC-4 + G-0236: sha must resolve to a commit that is either reachable from HEAD (the primary AC-4 case) OR present in the local object database as an orphan (the G-0236 reflog-fallback case). The fallback supports acks against `isolation-escape-orphaned-ai- commit` findings, whose offending SHAs are by construction unreachable from HEAD (they're force-pushed-away tips surfaced via the reflog walker at internal/check/reflog_walk.go).

Typo guard preserved: a SHA that resolves to no commit fails both checks and is rejected. The per-SHA closed-set scoping (each ack covers only the named SHA, and rules only fire on SHAs they independently enumerated) bounds the silencing surface — accepting object-DB-present SHAs doesn't widen any rule's reach.

Returns a Result with a Plan carrying the empty commit's trailers. The Apply pipeline materializes the `git commit --allow-empty` once the human gate clears.

func AcknowledgeMistag added in v0.18.0

func AcknowledgeMistag(ctx context.Context, t *tree.Tree, id, actor, reason string) (*Result, error)

AcknowledgeMistag records a sovereign acceptance that an entity's area tag and its commits' landing zone legitimately disagree — the escape valve for the area-mistag check (M-0181/AC-6). Like AcknowledgeIllegal it is a current-day empty commit, but keyed per-ENTITY rather than per-SHA, carrying:

aiwf-verb: acknowledge-mistag
aiwf-entity: <canonical-id>
aiwf-actor: human/<name>
aiwf-reason: <free-form text>

The check's WalkAcknowledgedMistags walks HEAD for these commits and exempts the named entities from area-mistag. The acknowledgement lives in git (queryable via aiwf history), aligns with the existing sovereign-act semantics, and does not pollute aiwf.yaml — the same rationale as acknowledge-illegal.

Constraints (mirroring acknowledge-illegal):

  • reason must be non-empty after trim (sovereign acts require a written rationale);
  • actor must be `human/...` (no LLM / bot ack — the cross-cutting judgment is the human's);
  • the id must resolve to a real entity (composite AC ids roll up to their milestone); a typo is refused rather than recording a no-op ack that silently suppresses nothing.

"What verb undoes this?" — none, by deliberate design (the acknowledge-illegal answer): the ack is one-way; if regretted, the operator re-tags the entity (`aiwf set-area`) so the mistag no longer fires, or lives with the suppressed finding. The verb is human-sovereign by construction.

func Add

func Add(ctx context.Context, t *tree.Tree, kind entity.Kind, title, actor string, opts AddOptions) (*Result, error)

Add creates a new entity of the given kind. Allocates the next free id, builds the entity, projects it onto the tree, runs `aiwf check` against the projection, and either returns findings (no changes staged) or a Plan that the orchestrator applies.

For contracts with all three Bind* options set, Add additionally splices the binding into aiwf.yaml.contracts.entries[] and the returned Plan carries a second OpWrite so the entity creation and the binding land as a single commit.

Returns a Go error only when arguments are malformed (missing required option, parent epic not found, contract-only flag on non-contract kind, partial bind triplet). Tree-integrity issues arising from the addition are returned as findings, not errors.

func AddAC

func AddAC(ctx context.Context, t *tree.Tree, parentID, title, actor string, tests *gitops.TestMetrics) (*Result, error)

AddAC creates a new acceptance criterion under the named milestone. Single-title convenience wrapper around AddACBatch — the existing signature is preserved so callers that want exactly one AC don't need to wrap their input in a slice. See AddACBatch for the batched-creation contract; this entry point applies the same rules with len(titles)=1. No body content is supplied; callers that want to populate the AC body in the same atomic commit use AddACBatch directly with a non-nil bodies slice.

func AddACBatch

func AddACBatch(ctx context.Context, t *tree.Tree, parentID string, titles []string, bodies [][]byte, actor string, tests *gitops.TestMetrics) (*Result, error)

AddACBatch creates one or more acceptance criteria under the same milestone in a single atomic commit (M-057). Each title gets a consecutive AC id starting at len(parent.ACs)+1, position-stable per existing rules (cancelled entries count toward position). A matching `### AC-<N> — <title>` heading is appended to the milestone body for each created AC.

When the parent milestone is `tdd: required`, every new AC is seeded with `tdd_phase: red` — the only legal starting state under the FSM.

Validation is whole-batch (M-057/AC-2): titles are checked first (empty-after-trim, not-prosey), then the milestone projection runs once with all N new entries; if any rule fires, the entire batch aborts with no commit. The plan returns exactly one OpWrite for the milestone file regardless of N (M-057/AC-4), so per-mutation atomicity is preserved.

The commit carries N `aiwf-entity:` trailers — one per created composite id, in allocation order (M-057/AC-3). `aiwf history M-NNN/AC-X` finds the commit because git's --grep matches any trailer line. The single-title invocation produces exactly one aiwf-entity trailer, matching pre-batch behavior (M-057/AC-5).

--tests is only meaningful when seeding a single AC into a tdd-required milestone (the original AddAC semantic). For N > 1 it is rejected; an LLM batching N criteria with one --tests value would otherwise silently apply the same metrics to every AC, which is almost certainly not what the operator meant.

When bodies is non-nil and bodies[i] is non-empty, its bytes are appended under the matching AC's `### AC-N — <title>` heading in the same atomic commit (M-067/AC-1). Other AC-067 ACs (count validation, frontmatter rejection, stdin pairing) bring their own rules in subsequent cycles; the AC-1 cycle does only the wiring.

Returns a Go error for setup failures (empty titles slice, empty or prosey title, milestone not found, kind mismatch, --tests with N > 1 or with non-tdd-required parent). Tree-level findings caused by the addition are returned in Result.Findings.

func Archive added in v0.8.0

func Archive(ctx context.Context, root, actor, kindFilter string) (*Result, error)

Archive sweeps terminal-status entities from the active tree into their per-kind `archive/` subdirectories per ADR-0004's storage table. The verb is multi-entity: one invocation rewrites every qualifying entity and produces a single commit (CLAUDE.md §7).

Behavior:

  • Default is dry-run: the verb computes a Plan and the caller prints planned ops without applying. `--apply` (caller flag) causes the dispatcher to run verb.Apply on the Plan.
  • Single commit per --apply per kernel principle #7. Trailer is `aiwf-verb: archive`; no `aiwf-entity:` trailer (multi-entity sweep, same shape as `aiwf rewidth`).
  • Idempotent. An already-swept tree returns a NoOp Result; the caller prints "no changes needed" and exits 0.
  • Sweep is by status, not by id. There is no positional id arg — ADR-0004 §"`aiwf archive` verb" rejects per-id housekeeping ("that would be a hand-edit detour, not a verb").

Per-kind storage table (verbatim from ADR-0004 §"Storage — per-kind layout"):

| Kind     | Active                              | Archive                                      |
|----------|-------------------------------------|----------------------------------------------|
| Epic     | work/epics/<epic>/                  | work/epics/archive/<epic>/ (whole subtree)   |
| Milestone| work/epics/<epic>/M-NNNN-<slug>.md  | does not archive independently — rides w/ epic|
| Contract | work/contracts/<contract>/          | work/contracts/archive/<contract>/           |
| Gap      | work/gaps/<id>-<slug>.md            | work/gaps/archive/<id>-<slug>.md             |
| Decision | work/decisions/<id>-<slug>.md       | work/decisions/archive/<id>-<slug>.md        |
| ADR      | docs/adr/<id>-<slug>.md             | docs/adr/archive/<id>-<slug>.md              |

`internal/entity/transition.go::IsTerminal` is the single source of truth for terminal statuses.

kindFilter scopes the sweep. "" sweeps every kind; a non-empty value must be one of entity.AllKinds().

func Authorize

func Authorize(ctx context.Context, t *tree.Tree, id, actor string, opts AuthorizeOptions) (*Result, error)

Authorize runs the `aiwf authorize` verb. Refusal rules per docs/pocv3/design/provenance-model.md §"The aiwf authorize verb":

  • Actor must be human/...; only humans authorize.
  • For AuthorizeOpen, the scope-entity must not be in a terminal status (overridable with Force + non-empty Reason).
  • For AuthorizePause, an active scope on the entity must exist.
  • For AuthorizeResume, a paused scope on the entity must exist.
  • Reason is required for pause/resume (non-empty after trim); optional for AuthorizeOpen unless Force is set.

Each invocation produces exactly one commit. The commit's diff is empty; Plan.AllowEmpty makes Apply use `git commit --allow-empty`. The agent is recorded in `aiwf-to:` (consistent with the existing trailer schema: the scope is the "entity" being acted on, with its target state encoded by who can act under it).

func Cancel

func Cancel(ctx context.Context, t *tree.Tree, id, actor, reason string, force bool) (*Result, error)

Cancel promotes an entity to its kind's terminal-cancel status — `cancelled` for epic/milestone, `rejected` for adr/decision, `wontfix` for gap, `retired` for contract. Errors when the entity is already in a terminal state or when the kind is unknown.

reason is optional free-form prose; when non-empty, it lands in the commit body so the cancellation's "why" is preserved for future readers. Empty reason matches today's body-less behaviour.

force=true emits an `aiwf-force: <reason>` trailer alongside the standard ones so the cancellation is auditable as a forced action. Cancel has no FSM transition rule to relax (it always sets status to the kind's terminal-cancel target), so force is purely an audit signal here. The "already at target" guard remains in place even under force — there is no diff to write. Force requires a non-empty reason; the caller is responsible for enforcing that.

func CancelAuditOnly

func CancelAuditOnly(ctx context.Context, t *tree.Tree, id, actor, reason string) (*Result, error)

CancelAuditOnly records that <id> was cancelled via a path that bypassed the kernel. Refuses when the entity is not already at the kind's terminal-cancel target. Composite ids dispatch to cancelACAuditOnly (which checks against the AC `cancelled` state).

func ContractBind

func ContractBind(ctx context.Context, t *tree.Tree, doc *aiwfyaml.Doc, current *aiwfyaml.Contracts, id, actor, repoRoot string, opts ContractBindOptions) (*Result, error)

ContractBind creates or replaces the binding for a contract entity in aiwf.yaml.contracts.entries[].

The verb is idempotent against an exact match (returns a NoOp result), errors out when the existing binding differs unless opts.Force is set, and validates that:

  • the contract entity exists in the tree;
  • the validator name is declared in aiwf.yaml.contracts.validators (unless current is nil — then the verb refuses, since there is no validator universe to choose from yet);
  • the bound schema and fixtures paths exist on disk (G18) — verified by running contractcheck.Run on the projected config and surfacing any introduced contract-config findings as a Result with Findings populated. Without this projection check, the only enforcement was the pre-push hook (a watch-point violation per design-lessons §2).

On success, the returned Plan carries one OpWrite for aiwf.yaml with the spliced contracts: block; the orchestrator commits it with the bind trailers.

repoRoot is the consumer repo root, needed to resolve schema and fixtures paths for the existence check. The CLI dispatcher passes the same value it uses to load the tree.

func ContractUnbind

func ContractUnbind(ctx context.Context, doc *aiwfyaml.Doc, current *aiwfyaml.Contracts, id, actor string) (*Result, error)

ContractUnbind removes the binding for a contract from aiwf.yaml.contracts.entries[]. The contract entity is left untouched; its status governs whether pre-push verification still runs (it doesn't, once unbound). Errors when no binding exists.

func EditBody

func EditBody(ctx context.Context, t *tree.Tree, id string, body []byte, actor, reason string) (*Result, error)

EditBody replaces the markdown body of an existing entity file. The frontmatter is left untouched — that stays the domain of the structured-state verbs (promote, rename, cancel, reallocate).

M-058 introduced the explicit-content path; M-060 added bless mode. The verb has two modes, dispatched on body:

  • body == nil: bless mode (M-060). Read the working-copy bytes and HEAD bytes, refuse if there is no diff (no changes to commit), refuse if the diff includes frontmatter changes (point at promote/rename/cancel/reallocate), commit the working-copy bytes verbatim with edit-body trailers. This is the natural human workflow: edit the file in $EDITOR, then bless the change with a verb route.

  • body != nil: explicit-content mode (M-058). The supplied bytes replace the body; the verb re-serializes the existing entity frontmatter with the new body and writes the result. This is the AI/script workflow — the body content was drafted elsewhere and is supplied via `--body-file <path>` or stdin.

Both modes refuse leading-`---` content via validateUserBodyBytes, refuse composite ids, return one OpWrite, and emit the same trailer set (`aiwf-verb edit-body`, `aiwf-entity`, `aiwf-actor`).

reason is optional free-form prose; when non-empty it lands in the commit body so future readers can see *why* the body was rewritten, not just *what* changed.

Returns a Go error for "couldn't even start": id not found, composite id, body validation failure, no-diff in bless mode, frontmatter-changed in bless mode. Tree-level findings caused by the projection are returned in Result.Findings.

func MilestoneDependsOn

func MilestoneDependsOn(ctx context.Context, t *tree.Tree, id string, deps []string, clearList bool, actor, reason string) (*Result, error)

MilestoneDependsOn writes the depends_on frontmatter array on a milestone. Closes the post-allocation half of G-072 (the create-time half is the --depends-on flag on `aiwf add milestone`).

Two modes, dispatched on `clear`:

  • clear == false: replace-not-append. The supplied `deps` list becomes the milestone's depends_on. To add a single dependency to an existing list, the caller passes the full updated list.
  • clear == true: empty the list. `deps` must be empty (the mutex is enforced by the dispatcher; this verb pins the contract).

Both modes emit one OpWrite with `aiwf-verb: milestone-depends-on` trailers, producing the kernel's per-mutation atomicity guarantee.

Each id in `deps` must resolve to an existing milestone; the verb refuses before the commit otherwise. Cycle detection stays at `aiwf check`'s layer — different concern, different chokepoint.

Forward-compatibility note: the verb shape `aiwf milestone depends-on M-NNN --on <ids>` is a clean subset of the future `aiwf <kind> depends-on <id> --on <ids>` cross-kind generalisation (G-073). The verb-name segment "milestone" is the *kind*; the generalisation extends to other kinds without renaming this verb.

reason is optional free-form prose; when non-empty it lands in the commit body so the rationale surfaces in `aiwf history`.

func Move

func Move(ctx context.Context, t *tree.Tree, id, newEpicID, actor string) (*Result, error)

Move relocates a milestone from its current epic to a different epic. The id is preserved (so references in other entities still resolve); only the file's location on disk and the milestone's `parent:` frontmatter field change. One commit per move with trailers `aiwf-verb: move`, `aiwf-entity: <M-id>`, `aiwf-prior-parent: <old-epic>`, `aiwf-actor: …` so `aiwf history` can answer "where did this milestone come from?" from either the milestone's or the old epic's perspective.

Returns a Go error for "couldn't even start": id not found, kind not milestone, target epic missing or wrong kind, milestone already under the target epic. Tree-level findings caused by the move (e.g. a depends_on cycle introduced by the new neighborhood) are returned in Result.Findings.

func Promote

func Promote(ctx context.Context, t *tree.Tree, id, newStatus, actor, reason string, force bool, opts PromoteOptions) (*Result, error)

Promote advances an entity's status. The transition is validated against the kind's FSM (entity.ValidateTransition) before any projection runs, so unknown statuses and illegal jumps are rejected with a clear error rather than as a `status-valid` finding.

reason is optional free-form prose explaining *why* the transition happens. When non-empty, it lands in the commit body (between subject and trailers) so future readers can see the why, not just the what. Empty reason produces a body-less commit.

force=true relaxes the FSM transition rule so any-to-any moves are permitted; coherence (closed-set membership of the target status, id format, ref resolution) still runs via projection findings, so promoting to an unknown status is still rejected. Force requires a non-empty reason; the caller (cmd dispatcher) is responsible for enforcing that. When force is set, the standard trailers gain `aiwf-force: <reason>` so the audit trail is queryable.

opts carries optional resolver pointers that need to be written in the same commit as the status change (see PromoteOptions). The resolver is validated against the entity's kind and the target status before any disk work — a mismatch is a Go error.

Returns a Go error for "couldn't even start": id not found, illegal transition (when not forced), resolver-flag/kind/status mismatch. Tree-level findings caused by the change are returned as a Result with non-empty Findings.

func PromoteACPhase

func PromoteACPhase(ctx context.Context, t *tree.Tree, compositeID, newPhase, actor, reason string, force bool, tests *gitops.TestMetrics) (*Result, error)

PromoteACPhase handles `aiwf promote M-NNN/AC-N --phase <p>`. Advances the AC's tdd_phase along the linear FSM (red → green → (refactor →) done). Mutex with status changes — the dispatcher rejects passing both a positional state and --phase. force=true skips the FSM transition rule but coherence (closed-set membership of newPhase) still runs via projection findings.

Trailers: aiwf-to: carries the new phase value (same trailer as for status changes; the verb name + composite id make it unambiguous which dimension moved). aiwf-force: when forced.

func PromoteACPhaseAuditOnly

func PromoteACPhaseAuditOnly(ctx context.Context, t *tree.Tree, compositeID, newPhase, actor, reason string) (*Result, error)

PromoteACPhaseAuditOnly is the audit-only variant of PromoteACPhase: refuses unless the AC's tdd_phase already equals newPhase. Same trailer + empty-commit shape as the status variant.

func PromoteAuditOnly

func PromoteAuditOnly(ctx context.Context, t *tree.Tree, id, newStatus, actor, reason string) (*Result, error)

PromoteAuditOnly records that <id> reached <newStatus> via a path that bypassed the kernel (manual commit, import, etc.). Refuses when the entity is not already at newStatus — audit-only never transitions, only documents.

Composite ids dispatch to promoteACAuditOnly. Top-level ids run against the per-kind FSM only insofar as the closed-set membership of newStatus must hold (an unknown status is rejected).

func Reallocate

func Reallocate(ctx context.Context, t *tree.Tree, idOrPath, actor string) (*Result, error)

Reallocate gives an entity a new id of the same kind, renames its file/dir to reflect the new id, and rewrites every reference to the old id — both in frontmatter and in body prose — across the whole tree, including the entity's own body.

The reference grammar (E-NN, M-NNN, ADR-NNNN, G-NNN, D-NNN, C-NNN) is regular and unambiguous, so prose rewriting is mechanical and safe. Word boundaries prevent false matches against longer ids (e.g., reallocating M-001 leaves M-0010 untouched).

The argument may be an id (e.g., "M-007") when unambiguous, or a repo-relative path (e.g., "work/epics/E-01-platform/M-007-cache.md") when the id is duplicated — required after a merge collision where two files share the same id.

The commit gets an aiwf-prior-entity: <old-id> trailer in addition to the standard three, so `aiwf history <old-id>` continues to find the entity's lifecycle even after the renumber.

func RecipeInstall

func RecipeInstall(ctx context.Context, doc *aiwfyaml.Doc, current *aiwfyaml.Contracts, name string, validator aiwfyaml.Validator, actor string, opts RecipeInstallOptions) (*Result, error)

RecipeInstall registers `validator` under `name` in aiwf.yaml.contracts.validators. The verb is idempotent on exact match (NoOp result), errors when an existing validator carries the same name with different fields unless opts.Force is set.

The returned Plan trailers carry one `aiwf-entity:` per binding currently referencing `name` in aiwf.yaml.contracts.entries[] so `aiwf history` for those contracts surfaces the recipe change.

func RecipeRemove

func RecipeRemove(ctx context.Context, doc *aiwfyaml.Doc, current *aiwfyaml.Contracts, name, actor string) (*Result, error)

RecipeRemove removes the named validator from aiwf.yaml.contracts.validators. Errors when one or more bindings in entries[] still reference the validator — the user must `unbind` or rebind those contracts first.

func Rename

func Rename(ctx context.Context, t *tree.Tree, id, newSlug, actor string, slugMaxLength int) (*Result, error)

Rename changes the slug portion of an entity's file or directory path. The id is preserved (per the design's "ids are immortal" invariant); the title in frontmatter is unchanged. Hand-edit the title in markdown if you want it to track the new slug.

For epic and contract (directory-based kinds), the directory itself is moved; nested files (milestones under an epic, the schema/ subdir under a contract) move with it. For file-based kinds, the single file moves.

For composite ids (M-NNN/AC-N), Rename dispatches to renameAC: the second argument is interpreted as a new title (not a slug), the AC's frontmatter title is updated, and the matching `### AC-<N>` body heading is rewritten in place. No path change.

Returns a Go error for "couldn't even start": id not found, slug produces an invalid path, source path missing on disk. Tree-level findings caused by the move are returned in Result.Findings. slugMaxLength caps the rewritten slug per `entities.title_max_length` (G-0102, kernel default 80). Title and slug share the same length budget so on-disk filenames and frontmatter titles stay in sync. Pass 0 from tests that don't care about cap policy.

func RenameArea added in v0.18.0

func RenameArea(
	ctx context.Context,
	t *tree.Tree,
	doc *aiwfyaml.Doc,
	members []config.Member,
	oldName, newName, actor string,
) (*Result, error)

RenameArea renames a declared workstream area (E-0044, M-0177). It rewrites the `areas.members` entry in aiwf.yaml from oldName to newName AND rewrites the `area:` frontmatter of every entity tagged oldName to newName, in one atomic commit — the same referential- integrity discipline `aiwf reallocate` applies to ids. Renaming an area by hand-editing aiwf.yaml would orphan every entity still carrying the old value (the `area-unknown` finding flags them and the grouping view buckets them into the untagged complement); this verb closes that hole.

members is the consumer's declared areas (the validated single source of truth from config.Load, passed by the CLI layer); the verb never invents members and reads it only to validate the rename. doc is the parsed aiwf.yaml the CLI loads for the comment-preserving splice, mirroring how ContractBind receives its aiwfyaml.Doc.

Validation (no Plan on failure, so nothing is written):

  • oldName and newName are non-empty and distinct;
  • oldName is a declared member;
  • newName is NOT already a declared member.

The commit carries `aiwf-verb: rename-area`, one `aiwf-entity:` trailer per rewritten entity (sorted by id for determinism), and `aiwf-actor:`. The `aiwf-verb` trailer suppresses the untrailered- entity audit; the per-entity trailers make the rename appear in each affected entity's `aiwf history`. When no entity references oldName, only the verb+actor trailers ride along (an aiwf.yaml-only change).

What undoes this? The same verb with swapped args: after `rename-area platform infra`, `rename-area infra platform` restores the prior member name and every entity tag.

func Retitle

func Retitle(ctx context.Context, t *tree.Tree, id, newTitle, actor, reason string, titleMaxLength int) (*Result, error)

Retitle updates the frontmatter `title:` of an existing entity (top-level kind) or AC (composite id). For top-level entities, the on-disk slug is also re-derived from the new title and the file is renamed atomically in the same commit (G-0108) — so frontmatter title and filesystem slug never drift apart. A canonical `# <ID> — <title>` body H1, if present, is rewritten to track the new title in the same commit (G-0083); bodies without a canonical H1 are left untouched, so an operator-shaped non-canonical heading is never silently clobbered. Use `aiwf rename` when you want a slug change without touching the title.

For composite ids (M-NNN/AC-N), Retitle dispatches to retitleAC, which updates the AC's title in the parent milestone's acs[] array AND regenerates the matching `### AC-<N> — <title>` body heading. Both changes land in one atomic commit per kernel rule. ACs have no slug, so no rename happens on the composite path.

reason is optional free-form prose; when non-empty it lands in the commit body so the rationale surfaces in `aiwf history`.

Returns a Go error for "couldn't even start": id not found, empty new title (after trimming), no-op (current title equals new title), or a title that slugifies to the empty string (e.g., punctuation- only). Tree-level findings caused by the projection are returned in Result.Findings.

titleMaxLength caps the new title per `entities.title_max_length` (G-0102, kernel default 80). Title and slug share the same budget; retitle is also the natural verb to migrate existing entities whose pre-cap titles are over the cap (the operator picks the shorter form). Pass 0 from tests that don't care about cap policy.

func Rewidth

func Rewidth(ctx context.Context, root, actor string) (*Result, error)

Rewidth sweeps a consumer's active planning tree from narrow legacy id widths (E-NN, M-NNN, G-NNN, D-NNN, C-NNN) to canonical 4-digit width (E-NNNN, M-NNNN, G-NNNN, D-NNNN, C-NNNN). Per ADR-0008:

  • Default is dry-run: the verb computes a Plan and the caller prints planned ops without applying. `--apply` (caller flag) causes the dispatcher to run verb.Apply on the Plan.
  • Single commit per --apply per kernel principle #7. Trailer is `aiwf-verb: rewidth`; no `aiwf-entity:` trailer (multi-entity sweep, same shape as `aiwf archive`).
  • Active-tree only. Files under `<kind>/archive/` are skipped entirely per ADR-0004's forget-by-default principle.
  • Idempotent. An already-canonical or empty tree returns a NoOp Result; the caller prints "no changes needed" and exits 0.

The verb walks each kind's active directory in a fixed sequence (epic, milestone, gap, decision, contract, adr) and within a kind iterates in alphabetical order by current filename. Determinism is load-bearing: a second invocation on the same tree visits files in the same order and produces zero ops.

Three reference patterns are rewritten in active-tree markdown bodies:

  • Bare id mentions in prose (`E-22` → `E-0022`). Word-boundary guarded so `E-220` doesn't match.
  • Composite ids (`M-22/AC-1` → `M-0022/AC-1`).
  • Markdown links to active-tree paths (`(work/epics/E-22-foo)`). Links targeting `<kind>/archive/...` are excluded by design.

Code fences (triple-backtick) and inline-code spans (single-backtick) are excluded from rewriting — content inside them stays as-is.

The F-prefix is included in the regex by spec (planned 7th kind from the §07 TDD architecture proposal). No F entities exist today, so it's a forward-compatible no-op for current consumers.

Rewidth does not call check.Run on a projected tree the way other verbs do: the operation is purely structural (rename + body rewrite) and `aiwf check` is the chokepoint for post-migration validation. The pre-push hook will run check after the user pushes the rewidth commit; spurious mid-verb check noise from a tree mid-rename is not what we want.

func SetArea added in v0.18.0

func SetArea(
	ctx context.Context,
	t *tree.Tree,
	members []string,
	id, member string,
	clearTag bool,
	actor string,
) (*Result, error)

SetArea points a single entity at an existing declared area member — or clears its area tag — in one trailered commit (E-0044, M-0183). It is the membership-change sibling of RenameArea: where RenameArea rewrites the *vocabulary* (a member's name, carrying every referrer), SetArea changes one entity's *membership* against a fixed vocabulary.

It is the guaranteed remediation for `areas.required` (M-0178): when the knob flags an untagged entity, `aiwf set-area <id> <member>` is the one-command unblock. It also owns the inverse — `aiwf set-area <id> --clear` untags an entity back to the untagged state (legitimate and never-flagged unless `areas.required` is set, under which an untagged entity is itself flagged by area-required), the clean correction for a mis-tag.

Two modes, dispatched on `clear`:

  • clear == false: set the entity's area to <member>. <member> must be a declared member of `members` (the validated single source of truth the CLI passes from config.Load); the verb never invents a member (that is RenameArea's and config's job). With no areas block declared, `members` is empty and every member is undeclared, so a set refuses.
  • clear == true: empty the area field. `omitempty` on entity.Area drops the cleared key on serialize, so the on-disk frontmatter returns to the untagged shape byte-for-byte.

Validation precedes any Plan, so a refusal writes nothing:

  • a composite/AC id or a milestone target refuses — area derives from the parent epic; the message names the epic and the remediation command;
  • an unknown id refuses;
  • <member> and clear given together refuse (mutex);
  • a non-empty <member> not in `members` refuses, naming the declared set;
  • a no-op (already tagged <member>, or --clear on an already-untagged entity) refuses.

The commit carries `aiwf-verb: set-area`, `aiwf-entity: <canonical id>`, and `aiwf-actor:`. The verb trailer suppresses the `provenance-untrailered-entity-commit` audit a hand-edit would trip — the whole point of the verb, for tag, retag, AND untag.

What undoes this? The same verb, total: a tag (untagged→tagged) reverses with `--clear`; a retag reverses with the prior member; a `--clear` reverses by setting the prior member. One verb owns one field with a complete reversal story.

type ScopeOutOfReachError added in v0.9.0

type ScopeOutOfReachError struct {
	// Actor is the non-human operator whose act was refused.
	Actor string
	// Target is the entity the verb would have mutated (the destination,
	// for a move). Empty for a creation act, where Refs carries the
	// proposed outbound references instead.
	Target string
	// Refs are the creation act's proposed outbound references, used for
	// the message subject when Target is empty.
	Refs []string
}

ScopeOutOfReachError reports that an authorized agent's verb was refused because its target falls outside the three-edge reach (D-0006) of every active scope attached to the actor. It implements entity.Coded, carrying check.CodeProvenanceAuthorizationOutOfScope — the same code the check-time provenance audit emits for the identical violation (one predicate, two enforcement times; D-0014).

func (*ScopeOutOfReachError) Code added in v0.9.0

func (e *ScopeOutOfReachError) Code() string

Code returns check.CodeProvenanceAuthorizationOutOfScope.ID, satisfying entity.Coded.

func (*ScopeOutOfReachError) Error added in v0.9.0

func (e *ScopeOutOfReachError) Error() string

Error implements error, naming the actor and the unreachable subject and including the code id so message-matching consumers recognize it.

type VerbKind

type VerbKind int

VerbKind discriminates the act being gated. Different kinds use different reachability rules per scopeAllows: a creation act checks the new entity's outbound references against the scope- entity; a move act requires both endpoints to reach scope; every other act checks only the target. Step 6's first cut covers the simple act (target-only); creation and move become relevant when the cmd-level wiring lands them.

const (
	// VerbAct is the default: the act has a single target entity
	// (promote, cancel, rename frontmatter changes, etc.). The
	// target must reach the scope-entity.
	VerbAct VerbKind = iota
	// VerbCreate is a creation act: a new entity is added with
	// outbound references. At least one outbound reference (or
	// the parent, for milestones) must reach the scope-entity.
	VerbCreate
	// VerbMove is a relocation act: both source and destination
	// endpoints must reach the scope-entity.
	VerbMove
)

VerbKind values.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL