verb

package
v0.8.1 Latest Latest
Warning

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

Go to latest
Published: May 21, 2026 License: Apache-2.0 Imports: 21 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

This section is empty.

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 directly to disk (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
}

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
}

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: denied with "no active scope authorizes this act" (corresponds to finding code 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 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  string
	Reason 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 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 OpType

type OpType int

OpType discriminates between file operations.

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

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