Task and command
Two words that sound like synonyms and are not, and almost everything in the manifest follows from the difference. Read this chapter first: the rest of this half of the site assumes it.
A task is a template. It carries the body. A command is an instance of one. It carries the name, the group, and the values pinned for this particular placement.
Nothing else in the model is allowed to blur that. A body is declared once, in one place; a name in the command tree is always a placement of something already declared. So “where does this command’s code live” has exactly one answer, and “what happens if I change this body” has a knowable one - it changes for every placement of it, everywhere.
The two declarations, side by side
tasks:
wheel:
impl: "orchestrator.cli:build_wheel"
help: "Build the wheel."
groups:
build:
commands:
wheel: { task: "wheel" }The tasks: entry is the template: a name, a body, and the wording that goes with the body. The
groups: entry is the placement: this template, under this group, called this. They are two entries
because they answer two questions, and a product very often wants the same answer to the first one and a
different answer to the second one twice over.
| task | command | |
|---|---|---|
| answers | what does this do | what is it called, where does it live, with what data |
| declared under | tasks: |
groups: <group>: commands: |
| how many of the other | any number of commands | exactly one task (or none, if it is an aggregate) |
| may name a body | yes, and must | never |
| may pin a value | never | yes, with with: |
Which keys belong to which
A task takes exactly five keys:
impl help passthrough_args params unattendedA command takes those it needs to place a task, plus the ones that describe this placement:
task with help params hidden keep_awake stop_on_failure parallel depends_on overrideThree of the task’s keys are inherited by every command that instantiates it unless the command says
otherwise: help, passthrough_args and unattended. That is the template doing its job - one task
documents itself once, and every placement gets that wording for free while remaining free to override
it.
unattended (si#267) is the newest and the one worth a sentence of its own: it says whether a pipeline
may run this command with nobody at the keyboard. It belongs to the task rather than to the command
because it is a fact about the body - test:walk asks a person to answer each acceptance scenario, and
it asks that of every product that imports the coordinate. Declared once in the platform catalogue, it is
true for everybody, which is what lets the kernel derive a pipeline
from a command tree instead of making each product write one.
It has three states and not two. unattended: true and unattended: false are answers; leaving the
key out is nobody has said, and that is neither. A derivation cannot guess it either way - reading it
as false drops a gate and the pipeline goes green for the wrong reason, reading it as true puts a command
that wants a person on a runner - so an undeclared command is named and refused instead. It is also the
one flag on a spec that is not coerced: every other is bool(value), and bool(None) is False, which
would destroy the third state at the loader’s own door.
impl is not in that list, because it is never the command’s to declare. with is not in it either, and
the reason is worth stating plainly: a template that pinned a value would not be a template. It would
be one particular use of itself, and the second product that wanted the same body with different data
would have to fork it. The same argument covers hidden, keep_awake, stop_on_failure, parallel
and depends_on - a template that hid itself, or planned other commands, has stopped being a template
and become a use.
The five refusals
Each of these is a load error with the offending name in it, not a warning and not a silent drop. They are worth reading as a group, because together they are the whole of the rule.
A command that declares a body.
command ‘build wheel’ declares
impl:. A command is an instance of a task: declare the body once undertasks:and point this command at it withtask:.
A task that declares a command’s key.
task ‘wheel’ declares
with:, which belongs on a command rather than on the task it instantiates. Move it to the command undergroups:.
A task that declares a key nobody reads. Anything outside the five is rejected rather than dropped, including keys that read as entirely plausible on a template:
task ‘wheel’ declares unknown key
group:. A task takes impl, help, passthrough_args, params, unattended - check the spelling.
A task with no body at all. The message names the alternative, because the mistake it usually represents is reaching for the wrong construct:
task ‘all’ declares no
impl:. A task is a template for a body, so it names one as “module:function”; a command that plans other commands usesdepends_on:instead.
A task that no command instantiates.
task ‘wheel’ is declared and no command instantiates it. A template nobody uses is a dead declaration: add a command for it under
groups:, or delete it.
That last one is scoped to the product’s own tasks: on purpose. The kernel’s catalogue deliberately
declares more than it places: a task that needs a manifest section or a running lab must not become a
baseline command that dies on its first line in every product that has neither.
The colon tells you which tasks: is meant
A command’s task: value is resolved by one function against two sources, and the colon is the whole of
the decision:
task: value |
Where the template comes from |
|---|---|
wheel — no colon |
a task this manifest declares under its own tasks: |
docs:site — a colon |
a catalogue coordinate: the template lives in the kernel |
The two name spaces cannot intersect - a bare name can never contain a colon - so nothing shadows anything and there is no precedence rule to memorise. A bare name is yours; a coordinate is the platform’s. A value that resolves in neither is a load error that lists what is available in the source it looked in:
command ‘build site’ names no task: ‘docs:website’ is not declared by the platform catalogue. A name with a colon is a platform coordinate, a name without one is a task in this manifest’s
tasks:. Available there: docs:reference, docs:render, docs:site, support:claude-plugins, …
A coordinate is deliberately not a module path. The body behind docs:site can move inside the kernel
without breaking a single product manifest, and that indirection is the entire reason the catalogue
exists.
One template, several commands
This is the payoff, and it is the reason the split earns its keep:
groups:
test:
commands:
unit: { task: "test:gate", with: { name: "unit" }, help: "Run the unit suite." }
system: { task: "test:gate", with: { name: "system" }, help: "Run the system suite." }
smoke: { task: "test:gate", with: { name: "smoke" }, help: "Run the smoke suite." }Three commands. One body. The only difference between them is a value the manifest pinned.
A pinned parameter is absent from the command line. It is not an option with a default that can still
be overridden - it is removed from the generated signature and supplied at call time. myctl test unit --name system is not a command, and that is the point: the manifest decided, so there is nothing left to
decide at the prompt.
The one conflict that follows from this is checked, because it would otherwise be invisible:
command ’test unit’ pins name with
with:and also declaresparams:for it. A pinned parameter is off the command line, so its presentation renders nowhere - drop theparams:entry, or drop the pin if the user should still be able to set it.
Note the scope: this is about the command’s own params:. A parameter the template documents and
this command pins is the design’s canonical shape - one task documents a parameter once, every instance
may pin it - so the pinned key is simply dropped from the merged presentation rather than rejected. Any
sibling command that leaves it unpinned still gets the documentation.
The third kind of command: an aggregate
A command need not instantiate a task at all. It can instead be a plan over other commands:
docs:
help: "Write the command reference, then build the website from it."
depends_on: [reference, site]task: and depends_on: are mutually exclusive, and a command must have one of them:
command ‘build docs’ declares both
task:anddepends_on:. A command either instantiates a task or plans other commands, never both - split it into two.
command ‘build docs’ declares neither
task:nordepends_on:. A command either instantiates a task or is an aggregate that plans other commands.
An aggregate is still a command - it has a name, a group, a help text and a place in the tree. What it
does not have is a template, which is why depends_on is a command key and could never be a task key.
The rest of the mechanics - post-order walk, dedup, list order as execution order - are in the
manifest.
Refinement, and deliberate replacement
Because the catalogue places some commands itself, a product’s tree and the platform’s can name the same command. Merging is per key, not per node, so a product refining one thing keeps everything else:
- point at the same
task:(or name none) and changehelp:,params:orwith:- that is a refinement, and it is silent, because it is the mechanism working; - point at a different
task:and it is not a refinement. It is a second, different body under one name, and the loader stops rather than letting one dictionary win over the other.
The rejection names both bodies by their real module:function rather than by the coordinate they share:
command ‘support install’ redeclares
task:from ‘support:install’ to ‘install’ - two different bodies placed under one name: the platform’s issimplon.tasks.hosttools:install, the product’s isorchestrator.cli:install. Which one runs is exactly the silent choice this loader refuses to make -installandinstalllook the same,simplon.tasks.hosttools:installandorchestrator.cli:installdo not. If the product’s body must deliberately replace the platform’s here, addoverride: true[…]
install and install read identically; two module paths do not. A human cannot act on the first pair
and can act on the second. override: true is the explicit yes, and an overriding node then stands
alone rather than merging with the base’s help: and params: - those describe the body that no
longer runs. This has its own rule.
One more shape is refused for the same reason: refining a task-backed command into an aggregate, or the reverse. Moving a command between the two is a different command wearing the same name, and it is asked to have its own.
Offered, or placed
A catalogue task can reach a product two ways, and the difference decides who chooses.
Offered is the default. The task exists at its coordinate, and a product that wants it declares a
command pointing at it. Most tasks are here: docs:site, release:artifact, test:gate and the rest
all need a section in the product’s manifest, so a command placed for everyone would die on its first
line in a product that declared nothing.
Placed means the kernel writes the command into its own tree, and every product using the tree form gets it. There is no way to decline one: the two trees are unioned, and a product cannot subtract.
That absence of a veto is what sets the bar. A placed command has to be useful in every product, not merely harmless in most:
- it reads nothing from a product manifest, so it cannot fail for lack of declaration;
- it acts on the machine or the repository, which every product has;
- and a product that never runs it is no worse off for having it.
support install, the support git verbs and support tasks pass all three. release:tag does not,
and it is the case that fixed the rule: it publishes — it cuts a tag and pushes it, so a misfire is
public and cannot be taken back, and a product with no tag-triggered workflow has nothing waiting for
that tag. Offering it costs a product one line in its manifest. Placing it would cost every product a
command it never asked for, pointed at its own origin.
Simplon releases by tag, so simplon declares it — which is exactly the shape a product should copy.
Placement or family: which half of a coordinate says where it goes
“Offered, or placed” above decides whether the kernel hands a command to everybody. This is the question underneath it, and it applies to every coordinate whether the kernel placed it or a product did: where may this one go?
A coordinate that starts with one of the platform’s own group names belongs in exactly that group. Any other namespace is a family, and the product places it where it likes.
So build:image is under build, in every product, always; docs:site is a documentation task and says
nothing at all about when it runs.
Six groups, five phases. The platform declares six top-level groups, and they are not six of the same
thing. build, test, release, deploy and monitor are the phases of the delivery loop, and they
have a chapter of their own: what each means, what flows between them, and what the
catalogue carries for each today. support is not a sixth: it is the group that supports those
five, which is why it sits beside them rather than among them.
The rule above is deliberately stated over groups rather than phases, and it has to be: support:install
is a real placed coordinate, and a rule that only knew about phases would be the one rule leaving it
ungoverned. So all six group names carry a placement, and “phase” keeps meaning the five things it means
everywhere else on this site.
The four names a product reaches for, and where each one goes
A product adopting the kernel tends to want the same four groups beyond the six, and si#286 asked whether one of them belongs in the taxonomy. Measured over every manifest this family can reach: none of them does, and no product that has adopted declares a group outside the six.
| what a product wants to write | where it goes | why |
|---|---|---|
lint: |
a command in test |
it verifies. test lint, test lint-backend |
contract: |
test when it checks a contract, build when it generates from a schema |
the group is what the command DOES, and only that product knows which |
ci: |
a command or an aggregate in test |
a pipeline is not a phase; it names the phases it runs |
dev: |
an entry in environments: |
it is a place to deploy to, reached as dev deploy up |
The evidence is not four opinions. dev is an environment in five of the eight manifests and a group in
none; ci is a workflow key in three and a group in none; and one product had already filed lint,
check-contract and ci as members of its own test group before the question was asked. Its reason is
the one this table is built on: they verify, so they belong in the group that verifies - a group of
their own would only repeat the name.
ci is worth one more sentence, because it is the name that looks most like a group and is least like
one. A CI pipeline runs the phases; it is not a phase. si#267’s derive: now writes that
pipeline from the command tree, so a ci group would be a third spelling of something that already has
two homes.
Why two axes, and why this is what keeps them two
The namespace says the family; the group says the placement. They are two questions because products genuinely answer them differently:
# one product builds its site as part of the build
build:
commands:
site: { task: "docs:site" }
# another publishes it, and files the very same task there
release:
commands:
site: { task: "docs:site" }Both are right, and no rule should have to pick. That is exactly what the two alternatives on the table
would have cost. Making every coordinate thematic (image:build, pytest:gate) would have renamed every
product manifest to say something they already said. Making every coordinate a group name would have
deleted the second axis outright - and would have broken on vcs:commit first, because committing
belongs to no single phase.
What the rule removes is neither axis. It is the third state: a namespace that reads like a group and is placed somewhere else. That case is the only one where a reader cannot tell which axis a name is on - and, before this rule, nothing stopped it from appearing.
What it costs today: nothing
Measured over both manifests that exist before the rule was written - simplon’s own and agile-cockpit’s - zero placements violate it. The rule does not rename anything; it writes down what both files already do, and turns a habit into something the loader holds.
It holds over the merged tree, so the commands the catalogue places itself - support install, the
support git verbs - are ruled on beside the product’s own. A rule the kernel exempted itself from would
be a rule about other people’s manifests.
The refusal
Placing a group-named coordinate anywhere but its group is a load error. It names both coordinates - the one in the tree and the one in the catalogue - the group that is allowed, and the two ways out:
command ’test image’ places the coordinate ‘build:image’, and ‘build’ is one of the platform’s own group names. A coordinate that starts with one says where the task belongs, so ‘build:image’ belongs under
groups: build:and nowhere else - this places it under ’test’. Move the command togroups: build: commands: image:, or - if this body really is a family each product places where it likes - give it a namespace in the platform catalogue that names no group, the waydocs:sitecan sit underbuildin one product and underreleasein another. The names that carry a placement are the platform’s groups: build, deploy, monitor, release, support, test - the phases of the delivery loop, plus the group that supports them
Two details of the scope are worth stating, because both are deliberate:
- Only the first segment of the path decides.
support git commitis insupport, so a coordinate filed one shelf deeper inside its own group is placed correctly - the rule is about which group a task runs in, not which shelf it sits on inside one. The message spells that path the way your manifest does (support git), not the way the tree keys it. - A bare
task:name is outside the rule. A name without a colon is a task this manifest declares itself; there is no namespace to read, and where a product files its own body is its own business.
How to read a manifest with this in hand
Three questions, in this order, will tell you what any entry is:
- Is it under
tasks:or undercommands:? That is template versus placement, and it settles which keys are even legal. - Does its
task:value contain a colon? That settles where the body comes from - this file, or the kernel. - Does it have
depends_on:instead? Then it has no body at all; it is a plan, and its steps are other commands in the same tree.
With those three answered, The manifest is a description of a file rather than a set of new ideas.