Skip to main content
Govern a task | AEPPut a task of your own under a profile — the task file, the artifact manifest, choosing a profile, and keeping the documents honest in CI.AEPhow-toaephow-toadopterdevelopertry-spec-driven-developmentunderstand-safe-agentic-codinggovern-work

Govern a task

This guide takes a team that already has rules — in a wiki, a CONTRIBUTING.md, or one senior engineer's head — and puts a real task under the protocol. Commands assume B=target/debug/aep after cargo build -p aep-cli, the convention Getting started sets.

What you write

Three things, and only the first two are files of yours:

You provideWhat it isSmallest useful form
task.yamlwhat is being done, and which profile governs it5 lines
artifacts.yamlwhere the specs, designs, ADRs and reviews live — references, not copies1 entry
a profile namewhich bundle of rules appliesdevelopment.fast / .standard / .critical / .driven, or your own

Everything else — workflow, principles in force, capabilities, completion condition — is derived from the profile. Restating any of it in the task is how it drifts.

A task that resolves:

id: BILL-88
kind: feature
objective: move-invoices-to-partitioned-table
protocol: adp/1
profile: development.standard
manifest: artifacts.yaml
constraints:
facts:
# Declared here because nothing can observe it. A principle reads it to
# decide whether it applies.
change.database_schema: true
change.public_contract: false

And the manifest it points at:

version: aep.artifacts/1

artifacts:
- id: spec:invoice-partitioning
kind: specification
status: approved
location:
path: docs/specs/invoice-partitioning.md

- id: plan:invoice-partitioning
kind: migration-plan
status: approved
location:
path: docs/migrations/0007-partition-invoices.md
relations:
- derived_from: spec:invoice-partitioning

manifest: is resolved relative to the task file, so the two sit beside each other. The manifest holds no copies: the design stays in docs/, the PRD stays in the planning tool, and neither moves for the protocol to reason about them.

Where the documents live

The loader walks six directories under one root, recursively, skipping anything dot-prefixed (crates/edge/aep-project/src/load.rs). Vendor the upstream documents (submodule, subtree or copy — the loader cares about content, not provenance) and put yours beside them:

your-repo/
protocols/ vendored, unchanged — the declared vocabulary
workflows/ vendored, unchanged — unless you write your own state machine
principles/
upstream/ vendored
migration-has-a-way-back.yaml yours
profiles/
upstream/ vendored
acme-service.yaml yours — usually `extends:` an upstream profile
artifacts/lifecycles/ vendored
drivers/ step maps — what a harness *does* in each state

Documents are indexed by the id declared inside each file, never by path, so the subdirectory layout is yours to choose. drivers/ is the newest of the six and the only one a harness needs: a step map names the program, model or person that runs in each workflow state, and it is what the reference driver walks. A tree with no step map still validates — missing directories are not an error.

Loading this repository's own tree:

$ $B validate --root .
45 file(s): 3 protocol(s), 22 principle(s), 4 workflow(s), 6 profile(s), 8 lifecycle(s), 2 step map(s)
valid

Naming the tree once, in a project file

Passing --root, --task and --artifacts on every invocation is fine for one command and tedious for a session. .engineering/project.yaml names them once, and every verb that takes those three flags discovers them from the working directory upwards:

version: aep.project/1
protocol: adp/1
profile: development.standard
protocols: .. # where the document tree is, relative to `.engineering/`
schemas: schemas # project JSON Schema registry; this is also the default

With .engineering/project.yaml and .engineering/task.yaml in place, the flags become optional:

$ $B resolve
inputs project …/aep
task W4-1 (feature)
objective agent-eval-cases
protocol adp/1
profile development.driven
workflow adp/default (initial: receive)
principles spec-driven, test-driven, static-analysis, least-privilege, provenance-tracking, contract-testing, property-based-testing, approval-gates, reversible-changes
obligations 10

The first line says which it used, and carries the project's absolute path — abbreviated here. inputs project means it discovered one; inputs . and <task> means you passed the paths yourself.

Project-owned JSON contracts live under the configured schema registry and identify themselves by absolute $id; the path is location, not identity. aep govern schema validate <paths>… discovers the registry from this file, while --schemas exists for fixtures and non-project invocations.

Seeing the workflow the profile puts you on

The profile picks a workflow, and the workflow is where the guards live. aep govern workflow render draws it without running anything:

$ $B workflow render --id adp/default --format tui
Standard development workflow
adp/default/1 · 9 states · 9 transitions

· receive Receive intake

· specify Specify specification
│ artifact.specification.exists
· decompose Decompose decomposition, planning

· establish_verifiers Establish verifiers verification-setup
│ test.exists
· implement Implement implementation
│ diff.exists
· verify Verify verification
│ (tests.unit.failed == 0 and tests.contract.failed == 0 and static_analysis.errors == 0)
╰─◀ implement (tests.unit.failed > 0 or tests.contract.failed > 0 or static_analysis.errors > 0)
· adversarial_verify Adversarial verify adversarial-verification
│ evidence.missing == 0
· review Review review
│ review.approved
· complete Complete completion · terminal

(Colour stripped for this page.) The guard sits on the arrow, which is where it acts: verify goes back to implement when a test fails, and forward only when none does. --format also takes svg, html and png; --run <RUN> draws a driver run over the same picture.

Choosing a profile

Measured on the repository's worked example — same task, same artifacts, only the profile changed:

development.fastdevelopment.standarddevelopment.criticaldevelopment.driven
principles in force59159
obligations6101710
completion checks14244524
distinct evidence kinds owed5777

Reproduce any column: $B resolve prints the first two rows directly, and $B evaluate --format json carries the third and fourth — completion is the array, and the entries whose flavour is evidence are the kinds.

The evidence kinds barely grow between standard and critical — what grows is the number of runs and who has to sign.

ProfileChoose it whenWhat it costs
development.fastblast radius contained, contract surface private: internal tooling, scripts, a spikea spec, a failing test first, static analysis, provenance. It cannot request a review or an approval — a human is never in the loop
development.standardanything with an external consumer, persisted data, or a customer-visible path — the defaultadds contract tests and a property suite, and the ability (with it, the obligation) to ask a human
development.criticala silent defect is worse than a late delivery: auth, money, migrations, cryptoadds a mutation run, a differential run against the implementation being replaced, an invariant check, design-by-contract, adversarial verification, specification conformance, an approved design, and a fresh human review of that design
development.drivena model, not a person, is doing the typingdevelopment.standard plus command.execute, and nothing else. It exists because the planning store has no tool surface other than the protocol CLI, so a driven step under .standard cannot create an artifact at all — the run does not fail, it never moves

development.driven's grant is the one place in this directory where a profile widens what an agent may reach, and the profile's own header says so rather than leaving it to be discovered. The narrowing is a hook, not a capability: the driver's own per-call policy (decide_tool in crates/edge/aep-cli/src/drive.rs, answering the metaharness seam) denies any Bash call that is not one simple invocation of aep plan artifact … or aep observe trace …. That constraint is pattern-based and best-effort — granting command.execute grants a superset of the shell's reach, and a hook narrows it rather than making it a function of the capability.

The critical row's freshness requirement is the one that bites. The worked example's manifest holds the design at version 7 and the review approved version 3, and the evaluation says so in its own words:

$ $B evaluate --root . --task task-critical.yaml \
--artifacts examples/development-passkeys/artifacts.yaml \
--evidence examples/development-passkeys/evidence/04-review.yaml

✓ artifact design (approved) which designs a specification [completion]
✗ review of a design is approved (by a person) [completion]
the approved review of design:passkeys-auth was given against a different version

(task-critical.yaml is the shipped examples/development-passkeys/task.yaml with profile: development.critical.)

Walking a task on its evidence

The worked example ships five evidence files. Submitting them in order and asking the engine to advance shows the whole lifecycle. One file carries the task to implement and stops it there:

$ $B evaluate --task examples/development-passkeys/task.yaml \
--artifacts examples/development-passkeys/artifacts.yaml \
--evidence examples/development-passkeys/evidence/01-red-test.yaml \
--advance
inputs . and examples/development-passkeys/task.yaml
state implement (Implement)
transitions
implement -> verify [blocked]
guard: diff.exists
Task incomplete in `implement`:
✗ (tests.unit.failed == 0 and static_analysis.errors == 0 and evidence.missing == 0) [completion]
tests.unit.failed = 1; unobserved: static_analysis.errors; evidence.missing = 7
? specification.satisfied [principle spec-driven]
unobserved: specification.satisfied
✗ tests.unit.failed == 0 [principle test-driven]
tests.unit.failed = 1

✓ evidence test_result from test-runner (independent) [principle test-driven]
✓ test-runner must run [principle test-driven]

(Twenty-four completion lines; five shown, the marking what was cut.) Each line names what is owed, which document asked for it, and which of the three truth values it holds: observed and true, observed and false, ? nobody observed it. The blocked transition names its guard. Submit all five files on one command line and the same invocation reaches complete:

$ $B evaluate --task examples/development-passkeys/task.yaml \
--artifacts examples/development-passkeys/artifacts.yaml \
--evidence examples/development-passkeys/evidence/01-red-test.yaml \
--evidence examples/development-passkeys/evidence/02-implementation.yaml \
--evidence examples/development-passkeys/evidence/03-verification.yaml \
--evidence examples/development-passkeys/evidence/04-review.yaml \
--evidence examples/development-passkeys/evidence/05-provenance.yaml \
--advance
state complete (Complete)
transitions
(none: this state is terminal)
Task complete in `complete`:
✓ (tests.unit.failed == 0 and static_analysis.errors == 0 and evidence.missing == 0) [completion]

Every record in those files carries an observed_at. It has no default, and the omission is deliberate: a caller who has to write down when they looked is a caller who cannot accidentally claim they looked just now.

Letting the driver walk it

Everything above is a person typing evaluate. aep drive is the loop that does it — the reference driver, shipped 2026-08-21. It makes the engine's calls in order, executes the three kinds of step a map declares (command, llm, operator) and records what it did. It evaluates no gate itself, which is the point: a driver that could evaluate a gate would be a second protocol implementation with none of the conformance suites behind it.

$ $B drive run --project . --plugin-dir /path/to/agentplugins/plugins/aep-plan --map development/default \
--pause-on-approval
$ $B drive status
$ $B drive resume W4-1/1

drive run needs a model and costs money, so it is not runnable from a checkout alone; drive status reads the run directory and needs nothing. This repository's own first governed run is recorded there:

$ $B drive status
lock free
run W4-1/1
task W4-1
execution W4-1.1
workflow adp/default/1
map development/default (989b18fa5e87d0b7b9d4d4d3abe8865fb6c06d7d4759205118e9e19f92b695bc)
state establish_verifiers (step 2)
status blocked
iterations 9

establish_verifiers -> implement: ? artifact specification (approved) — declared: specification:agent-charter-eval-cases (draft) [principle spec-driven]
establish_verifiers -> implement: ✗ test.first_result == failed — test.first_result = passed [principle test-driven]

(The stands for the per-state visit and attempt counts.)

That output comes from a run on this machine. .engineering/runs/ is in .gitignore, so a fresh checkout has no runs and drive status will say so — the transcripts name the running account's spend and its MCP inventory, which a public tree should not carry.

The run itself is the honest part: it stopped four states short of the person it was meant to stop at, for two reasons the engine printed. Neither is a defect in the engine, and both are about the step map — drivers/development/default.yaml names cargo in every state that names a verifier, so a story whose acceptance is written in shell cannot satisfy test-driven at all. The decision that closed it was the first of the two the row offered: that file is a Rust map, it says so in its header, and drivers/development/checks.yaml is the map for work whose acceptance is a check somebody can run. Two maps now fit adp/default/1, which is why the command above names one — without --map the driver refuses to choose and lists both. The row lives in the repository's gap register, docs/plan/gap-register.md, which is where every open question in this project is written down beside what closes it.

Integrate an agent harness covers what the driver enforces, what it delegates to hooks, and what to do instead if you are writing your own.

Keeping the documents honest in CI

validate reads the tree on its own; resolve also needs a task, because some checks only mean anything once a profile and a workflow are paired:

Caught byRefusalOtherwise
validateunobservable_fact — a predicate reading a fact the protocol does not declarethe rule can never be satisfied; a task hangs
validateunreachable_statethe state is decoration
validatedead_end_state — a non-terminal state with no way outexecution wedges there
validateincomplete_rollback_policy"rolled back" describes a wish
resolveunknown_phase — an obligation timed against a phase no state declaresthe rule is not strict, it is absent
resolvecapability_conflict — a task needing a capability the policy deniesthe agent finds out mid-task

Both exit 1, and both accumulate all problems in one run. Add them to CI as their own step, and keep at least one representative task per profile you ship — validate alone cannot tell you that a principle is timed against a phase your workflow does not have:

- name: Documents
run: |
cargo run -p aep-cli -- validate --root .
cargo run -p aep-cli -- resolve --root . --task examples/development-passkeys/task.yaml

If your repository keeps a planning store as well, aep plan artifact validate belongs in the same step: it checks every file, every edge and every status in one run, and it is local, clock-free and sub-second.

Next