ADR-0044: Agent package stables — installable, trust-gated harness definitions shared as git repositories
Partially implemented. The daemon-side foundation is in the tree: the
package.tomlmanifest loader, the pin-store layout and thesourcekey on a[harness.*]table. Not yet built: theharness agentcommand, the[stable.*]table and every fetch, scan and confirmation flow. Tracked by the SPEC-0026 epic in the Harness issue tracker.
Context and Problem Statement
Every harness definition today is either hand-written in harness.toml
(📝 ADR-0006) or generated once by harness stack/harness init from a pinned,
first-party manifest (📝 ADR-0024). There is no way to receive somebody else's
harness definition — a PR-reviewer agent with a curated system prompt and
checklist skill, a triage agent, a release-notes writer — short of copying
TOML out of a gist and hoping its referenced skill directory comes along
correctly. The owner wants to publish an agent config repository and let
other people, and future selves on other machines, install one or many
agents from it with one command, the way brew tap plus brew install
works, and wants the trust and injection-prevention boundary built at the
same time as the feature rather than bolted on after a demonstrated
compromise.
Two properties of what would be installed make this a materially larger
trust ask than anything the tree already does. First, a [harness.*] table
is inherently an execution primitive: the effective adapter (📝 ADR-0039)
resolves it into a literal argv the daemon execs, so installing a package is
trusting its author to say run this, with these arguments — not merely
here is some text to search, which is all 📝 ADR-0030's skill repos ask.
Second, 📝 ADR-0011/📝 ADR-0039 already made bundled skill content always-visible,
projected context with no gate between installation and an agent reading it,
where 📝 ADR-0030 built a whole blind-reconstruction pipeline before any
machine-authored skill reaches an agent's context at all. A hand-authored
package has no merged pull request to verify itself against, so that pipeline
has nothing to bite on here.
What should a package be allowed to declare, how does a human decide a git remote is worth installing from at all, and what stops a package's bundled natural-language content from being a prompt-injection delivery vector the moment it is installed?
Decision Drivers
- The daemon stays agnostic and gains no new trust boundary. It must resolve an already-installed, already-pinned package from local disk exactly the way it resolves a hand-written harness table, and it must never clone, fetch, or reach a stable's git remote itself — the same rule 📝 ADR-0030 already enforces for skill repos ("the daemon never fetches, because a private remote would need a credential").
- Trust is an explicit, hand-authored act, never inherited from a cloned repository. The "no supply-chain opt-in" house rule 📝 ADR-0029 and 📝 ADR-0030 already enforce for their own global-only tables must cover the new trust table too.
- A package is data, never code. It resolves to the same closed schema a
hand-written
[harness.*]table already uses (📝 ADR-0006/📝 ADR-0039): no scripting language, no install-time hook. A malicious package cannot execute anything until the human explicitly starts the harness it defines, and everything it will do is visible in that one table beforehand. - A package cannot smuggle credentials. It must be structurally unable
to set
env_file,secrets_env, or a${NAME}reference (📝 ADR-0038) — those stay something only the installing human adds locally, after install. - Bundled natural-language content is a first-class injection surface, not
an afterthought. Because skills project as always-visible context with
no distiller-style review gate, every install and every upgrade must show
the human what they are about to trust, in full, before it happens — the
confirm-and-log discipline 📝 ADR-0023 already applies to one risky flag
(
untrusted_inline) generalizes here to an entire bundle. - Reuse 📝 ADR-0030's shape rather than inventing a fourth. A named external source with declared visibility, a managed local clone the daemon treats as read-only, explicit sync instead of implicit polling, and namespacing by source name are already-accepted patterns.
- One repository, many packages — the Homebrew-tap requirement — so a single git remote can offer a whole family of agents, discoverable and installable independently.
- No duplication with what already exists. 📝 ADR-0024's stack installer installs Harness itself and first-party infra bundles by digest-pinned manifest; 📝 ADR-0039's central adapter config configures how a client is invoked, not which harness runs. A package only ever selects an existing adapter by name and supplies harness-level values, never a new adapter, and never a new stack.
Considered Options
Four questions.
Decision 1 — What a package is, and how it's found
- Option 1 — A single global registry/index (npm- or crates.io-style), centrally curated and searched by name.
- Option 2 — Git-repo stables, Homebrew-style: any git remote the operator explicitly names can hold any number of packages under a fixed layout; no central index at all.
- Option 3 — Ungrouped, one-off installs from a bare URL or path with no
persistent trust record (
pip install <url>-style).
Decision 2 — How much a package manifest may declare
- Option 1 — Whatever a hand-written
[harness.*]table can declare, verbatim, includingenv_file/secrets_envand arbitrary adapter definition. - Option 2 — A narrowed, declarative-only subset of the harness schema: adapter selection and option values only, no secret-typed keys, no adapter/table definitions, no install-time hooks or scripts of any kind.
- Option 3 — A package is Turing-complete: an install script (Homebrew
formula / npm
postinstallstyle) that can fetch, build, or configure anything.
Decision 3 — What happens to bundled skills/text before an agent ever sees them
- Option 1 — Trust the stable once, at
stable add; every install/upgrade after that is silent. - Option 2 — Every install and upgrade — not just the first — runs a heuristic content scan and shows the human a full diff of the manifest and every bundled file, gated behind an explicit confirmation; a high-severity finding blocks by default.
- Option 3 — Route every package through 📝 ADR-0030's distiller pipeline (blind reconstruction, judge, adjudicator) before it is installable.
Decision 4 — Where an installed package's content lives, and how a harness references it
- Option 1 — Install materializes the package's resolved values directly
into
harness.tomlas an ordinary[harness.*]table, indistinguishable from hand-written config. - Option 2 — A harness table gains one new field,
source = "<stable>/<package>@<sha>", resolved at config-load time against an immutable, content-addressed local store; any other key on the same table is a local override layered over the package's declared values. - Option 3 — Packages are resolved live at every daemon start/reload directly from the stable's working clone, with no separate pin and no managed store.
Decision Outcome
Chosen: Decision 1 → Option 2 (git-repo stables), Decision 2 → Option 2
(declarative-only, narrowed schema), Decision 3 → Option 2 (scan + diff +
confirm on every install and upgrade, high severity blocks), Decision 4 →
Option 2 (a source pointer field over an immutable, content-addressed
local store).
In one sentence: a stable is a git remote the operator explicitly trusts
once (joining a global-only table, exactly like 📝 ADR-0030's skill_repo);
harness agent install <stable>/<package> clones or updates that stable's managed
local copy, resolves one package's manifest, scans its content, shows the
human everything it would change, and — only on confirmation — pins it by
commit SHA into a content-addressed store and writes one line (source = "...") into a harness table; the daemon's only new duty is resolving that
one line against the local, already-installed pin, so the entire
fetch-scan-confirm-trust surface stays outside the supervisor, exactly where
📝 ADR-0030 already put harness distill/harness skills sync.
The name is Harness's own, not Homebrew's: a stable is where harnesses are kept, and "a stable of agents" already reads as ordinary English. The mechanism is still the Homebrew tap model, so the rest of this record says "tap" only when it means Homebrew's.
Stables: the trust ledger, global-only
# ~/.config/harness/harness.toml. Global only: a project file declaring any
# of this is rejected, joining ADR-0009's list beside [adapter.*] and
# [skill_repo.*].
[stable.stump-wtf]
remote = "https://forge.example/your-org/harness-stable.git"
public = true # declared visibility, as ADR-0030; unset is treated as public
Amended 2026-10-09 for #937: the stable tree was promoted to a top-level
harness stable …; harness agent stable … remains a hidden alias. The
bullets below keep the original spelling.
harness agent stable add <name> <remote> [--public|--private]is the only way a stable is added — mirrorsbrew tapand 📝 ADR-0030'sskill_repo: adding trust is a deliberate, one-time, hand-run command, never an implicit side effect of installing anything.harness agent stable remove <name>deletes the table and reports every package still installed from it (removal does not uninstall them; see Uninstall below).harness agent stable update [name]is the only thing that ever fetches. Likeharness skills sync, the daemon never fetches — a private stable needs a credential, and that credential belongs to the CLI invocation, never to the supervisor.- A stable's clone lives at
$XDG_STATE_HOME/harness/agents/stables/<name>/, read-only from the daemon's perspective, exactly as 📝 ADR-0030's serving clone is never the operator's own checkout.
Layout: one repo, many packages
<stable-repo>/
packages/
pr-reviewer/
package.toml
skills/
review-checklist/SKILL.md
release-notes/
package.toml
harness agent search [query] and harness agent info <stable>/<package> read
the stable's local clone; nothing here needs the daemon at all, matching the
"client command, not daemon subsystem" split 📝 ADR-0030 already drew for
harness distill.
The manifest: narrowed, declarative-only
[package]
name = "pr-reviewer"
version = "1.4.0"
description = "Reviews PRs against the repo's ADRs and specs"
author = "..."
homepage = "https://..."
[harness]
harness = "claude-code" # selects an existing adapter (ADR-0039); never defines one
auto_accept = true
system_prompt_file = "prompt.md" # resolved package-relative
[requests] # itemized asks, shown verbatim at confirm
skill_paths = true # bundles ./skills
mcp_allow = ["read"]
network = false
[harness]accepts only the per-harness value keys 📝 ADR-0039 already closes over (argv* for acommandkind,model/model_pin,auto_accept,max_turns,quiet,system_prompt_file,mcp_config,allowed_tools,skill_paths* rewritten to the bundle,mcp_bridge,mcp_exclusive,mcp_policy), plus at most one one-shot prompt source (prompt,prompt_file,prompt_template,prompt_template_file). A package ships the instruction; thescheduleortriggersthat fire it stay on the operator's table, so installing a package never makes anything run on its own. Amended 2026-10-09: a package may now suggest a schedule, triggers, a timeout and the other supervision keys in a separate[defaults]table, which nothing reads at config load; only the operator's choice at install writes one onto the table (see the amendment at the end of this ADR). Every path it names stays inside the package. It rejectsenv_fileandsecrets_envoutright — a hard load error, never a silent drop — and any string value containing${, closing exactly the smuggling path 📝 ADR-0038 defined.[package]/[requests]are metadata; nothing under them is executable. No table other than[package],[harness],[requests]may appear:[adapter.*],[skill_repo.*],[stable.*],[mcp.*],[job.*],[server],[profile.*]are all load errors inside a package manifest, for the same reason a projectharness.tomlis barred from them (📝 ADR-0009, 📝 ADR-0039, 📝 ADR-0030).- No install-time hook of any kind exists. The only thing a package can
cause to run is the harness itself, and only when the installing human
later starts it — bounding the entire attack window to something
inspectable before it happens, which Homebrew's Ruby formulae and npm's
postinstalldo not give you. - A package selects an adapter; it never declares one.
harness = "nonexistent"is the same load error 📝 ADR-0039 already defines.
Install: scan, diff, confirm, pin
harness agent install <stable>/<package>[@<version>] [--as <name>]:
- Resolves
<version>(default: the stable's current default branch) to an exact commit SHA in the stable's local clone. Nothing floats past this point. When<version>is itself a full commit SHA already present in the local content-addressed store, resolution is entirely local — no stable clone lookup is needed, which is also how a rollback to a previously installed pin works. - Runs the content scan (a heuristic lint over the manifest and every
bundled markdown/text file), producing findings at
highorlowseverity. This is a tripwire, not a certification; the CLI output says so every time. - Prints the resolved manifest verbatim, the
[requests]table as itemized, human-readable asks, every bundled file's path, and — for an upgrade — a diff against what's currently pinned. Ahighfinding blocks unless--force-unsafeis given, which requires re-typing<stable>/<package>and is recorded in the install record. A request formcp_allowincluding"write"requires the same typed re-confirmation regardless of scan findings, because 📝 ADR-0010 already treats MCP write scope as a deliberate, documented acceptance of risk. - On confirmation (or
--yes, which never bypasses ahighblock), copies the package's files at that commit into$XDG_STATE_HOME/harness/agents/installed/<stable>/<package>/<sha>/— immutable, content-addressed, prior pins retained until pruned. - Writes or updates exactly one line in the target
[harness.<name>]table (default<name>is the package name;--asoverrides it, so the same package can be installed more than once under different local names) in the targetharness.toml:source = "<stable>/<package>@<sha>". Any other key already on that table (workdir,model, additionalskill_paths, …) is preserved and layers over the package's declared values as a local override — the same "nearest wins" rule 📝 ADR-0011's merge already uses.
harness agent upgrade <stable>/<package> re-runs steps 1–5 against the stable's
latest already-fetched state (stable update must be run first; upgrade
never fetches either) and is the only way an installed pin ever changes.
harness agent uninstall <name> removes the whole harness table (with
confirmation) and, once nothing references it, harness agent prune removes
the content-addressed entry.
Trust declaration is global-only; naming a package is not
[stable.*] joins 📝 ADR-0009's global-only list — a cloned repository must never
expand what is trusted. source itself is not global-only: a project
harness.toml may reference an already-installed <stable>/<package>@<sha>
exactly as it may already set skill_paths, because naming a pin trusts
nothing new. The pin must already exist in the local content-addressed
store — installed by an explicit prior command on that machine — or config
load fails exactly as it does for a missing pin anywhere else (see
Resolution, below). A project file cannot cause anything to be fetched,
scanned, or newly trusted merely by naming a source.
Resolution stays entirely in config load
At config load, a harness table with source set is resolved by reading
the pinned manifest from the local, already-installed store — never the
network, never the stable's live clone — merging its [harness] values under
the table's own local keys, and validating the result exactly as a
hand-written table would be. A source naming a pin that is not in the
local store (never installed, or the store was pruned) is the same class of
load error 📝 ADR-0011/📝 ADR-0039 already define for an unknown adapter: named,
specific, and it keeps the daemon on its last-good configuration (📝 ADR-0006).
This is the whole of the daemon's involvement — it never clones, fetches,
scans, or confirms anything.
skill_paths gains one more tier, below everything a human placed
A package's bundled skills/ directory becomes that harness's package tier
— inserted below SPEC-0006's four existing tiers, so anything the human
explicitly configured, globally or per-project, still wins a name collision:
package bundle (lowest) → adapter defaults → global skill_paths →
project skill_paths → project-local dirs (highest)
This is a one-line amendment to SPEC-0006's merge-order requirement, made explicit in SPEC-0026 rather than silently assumed.
Fleet-wide surfaces stay out of reach of any package. A bundled prompts/
directory is never auto-registered against 📝 ADR-0010's [mcp.prompts]
broker — that is a fleet-wide, all-harnesses capability grant, a materially
bigger ask than "add a skill to the one harness I explicitly installed this
for." If a package ships prompts, install output names the directory and
suggests the human add it by hand; nothing does it for them.
Consequences
- Good, because the entire fetch/scan/trust surface sits in the CLI, never the daemon, matching 📝 ADR-0030's split and keeping 📝 ADR-0008's minimal daemon attack surface true for a third external-content mechanism in a row.
- Good, because a package cannot smuggle a credential (📝 ADR-0038's
env_file/secrets_env/${NAME}ban) or a new execution primitive (no hooks, no scripting, adapter selection only) — the worst a malicious stable can do at install time is fail a scan; at run time it is exactly as scoped as a hand-written harness the operator typed themselves. - Good, because pinning by commit SHA plus a diff on every upgrade closes the Homebrew tap rug-pull shape: a stable that earns trust with a clean package and later adds injected content cannot reach a running harness silently — the human sees the exact diff before the new pin takes effect.
- Good, because
sourcekeepsharness.tomlsmall and hand-editable (📝 ADR-0006): one line names the package, local overrides stay ordinary keys, and the file never balloons with a copy of someone else's manifest. - Good, because reusing 📝 ADR-0030's shape (global-only trust table, managed
clone, explicit sync, credential split) means an operator who already
understands
skill_repoandharness distillalready understandsstableandharness agent. - Bad, because the content scan is a heuristic tripwire over natural language — it will miss a well-disguised instruction and will also flag legitimate imperative SKILL.md prose (📝 ADR-0011's own skills are full of MUST/ALWAYS). The mitigation is that the human sees the full content regardless of what the scanner says, not that the scanner is trusted to be complete.
- Bad, because a package's bundled skill content still becomes always-visible, projected context the moment its harness starts (📝 ADR-0011/📝 ADR-0039), with no distiller-style blind-reconstruction gate the way 📝 ADR-0030 built for machine-authored skills — the review gate here is entirely the installing human reading a diff once, not an automated fidelity check.
- Bad, because a fifth global-only table (
stable.*, alongsideadapter.*,skill_repo.*,[server],[profile.*]) is one more thing 📝 ADR-0009's project-file rejection list must enumerate and one more thing a config validator must keep in sync. - Bad, because two independent local git states now exist per stable plus a
content-addressed store per installed package — more moving parts on disk
than a single
harness.toml, mirroring the same cost 📝 ADR-0030 already accepted forskill_repo. - Neutral, because no signature or provenance mechanism beyond commit-SHA pinning ships in this revision — deferred, see More Information.
Confirmation
SPEC-0026 formalizes stable registration and the global-only rule, the manifest
schema and its forbidden-table/forbidden-key list, the content scan's
severity levels and blocking behavior, the install/upgrade/uninstall
lifecycle including the content-addressed store and the source field's
resolution at config load, and the skill_paths precedence amendment, as
testable requirements.
Acceptance tests that matter:
- A project
harness.tomldeclaring[stable.*]fails load, naming the file (the same shape as[adapter.*]/[skill_repo.*]today). - A package manifest declaring
env_file,secrets_env, or a string value containing${fails to install with an error naming the offending key. - A package manifest declaring any table other than
[package],[harness],[requests]fails to install, naming the table. - Installing pins an exact commit SHA even when
@versionnames a moving branch; a secondinstallof the samestable/packagewith no interveningupgraderesolves to the same pin. - An upgrade whose diff includes a
high-severity scan finding is refused without--force-unsafe, and refused even with--yes. - A package requesting
mcp_allow = ["read", "write"]is refused at install without the typed re-confirmation, independent of scan findings. - Uninstalling removes the whole harness table it created and no other; a
subsequent config load referencing the same
sourceelsewhere without a re-install fails naming the missing pin. - A harness resolved from a package's
sourceand a hand-written harness with identical values produce identical validated configuration — a package is not a distinct code path from the operator's own tables. - A name collision between a package-bundled skill and a project-local skill resolves to the project-local copy, with the package copy listed as shadowed.
- A project
harness.tomlreferencing asourcealready installed on that machine loads and starts; the same file on a machine where it was never installed fails, naming the missing pin, and fetches nothing.
Pros and Cons of the Options
Decision 1
Option 1 — Central registry
- Good, because search and discovery are trivial with one index.
- Bad, because it centralizes curation Harness has no team to do, and a single index is a bigger single point of trust and failure than a decentralized set of stables the operator names one at a time.
- Bad, because it does nothing the owner actually asked for: a Homebrew-style tap, explicitly, is a decentralized model by design.
Option 2 — Git-repo stables (chosen)
- Good, because it needs no infrastructure Harness doesn't already run — a
git remote, exactly like
skill_repo. - Good, because one repo naturally holds many packages, satisfying the stable requirement directly.
- Neutral, because discovery is per-stable (
searchinside a trusted stable), not global — a deliberate trade against Option 1's centralization risk.
Option 3 — Bare URL installs
- Good, because it is the least machinery.
- Bad, because it has no persistent trust ledger: every install re-asks the
human to trust a URL from scratch, with no
stable listto audit what's trusted, and namespacing collapses (two packages namedpr-reviewerfrom different sources cannot coexist as<stable>/<package>). - Bad, because it invites exactly the copy-paste-a-URL habit this feature exists to replace.
Decision 2
Option 1 — Full harness schema, unrestricted
- Good, because a package could express anything a hand-written harness can.
- Bad, because "anything" includes
env_file, which is precisely the credential-smuggling path this ADR exists to close.
Option 2 — Narrowed, declarative-only (chosen)
- Good, because the entire manifest is inspectable before anything runs — no hook, no script, no table it can't be shown to declare in full at install time.
- Bad, because a package cannot bundle a wrapper script or a build step; an author who needs one publishes it separately and references it by path, same as any hand-written harness would.
Option 3 — Turing-complete install scripts
- Good, because it is maximally flexible — Homebrew's actual model.
- Bad, because it is a code-execution plugin surface at install time, which 📝 ADR-0039 already rejected for adapters on identical grounds (its Option 3); doing it here for third-party content nobody reviewed is worse, not better.
Decision 3
Option 1 — Trust once at stable add, then silent
- Good, because it is the least friction after the first
stable add. - Bad, because it is exactly the tap rug-pull shape: a stable earns trust with a clean first package, then a later version — or a different package in the same stable — carries injected content with no second look from anyone.
Option 2 — Scan + diff + confirm every time (chosen)
- Good, because no install or upgrade ever changes what reaches an agent's context without the human seeing it first, matching the discipline 📝 ADR-0023 already applies to a single flag, generalized to a whole bundle.
- Bad, because it adds friction to every upgrade, including a trivial one-line fix, and repeated friction risks training the human to confirm without reading — the same risk any confirm-fatigue pattern carries.
Option 3 — Route packages through the ADR-0030 distiller pipeline
- Good, because it is the most rigorous check available in this codebase — blind reconstruction, judge, adjudicator, control run.
- Bad, because that pipeline verifies a claim against a merged pull request it can reconstruct; a hand-authored package makes no such claim and has no pull request to check against, so the verification machinery has nothing to bite on. Repurposing it would mean building a second, unrelated meaning for the same words.
Decision 4
Option 1 — Materialize resolved values directly
- Good, because
harness.tomlstays a single flat file with nothing indirect in it. - Bad, because every upgrade rewrites the entire stanza, turning a one-line
upstream tweak into a full-table diff, and two installed packages that
both set, say,
system_prompt_filecan't be told apart from hand-edits after the fact.
Option 2 — A source pointer field (chosen)
- Good, because an upgrade is a one-line pin change, trivially diffable,
and rollback is pointing
sourceback at a retained prior pin. - Good, because local overrides and package defaults compose the same way 📝 ADR-0011's precedence already works.
- Bad, because
harness.tomlalone no longer shows a package harness's full effective configuration —harness describe/harness agent infomust be reached for that, the same cost 📝 ADR-0039 already accepted for adapters.
Option 3 — Resolve live from the stable's clone every load
- Good, because there's no separate pin or store to manage.
- Bad, because a config reload could silently change a running definition if the stable's clone moved underneath it — reintroducing exactly the untracked, ambient trust this ADR exists to remove.
Architecture Diagram
More Information
- Extends 📝 ADR-0006 — adds
sourceto the harness table schema and a new global-only[stable.*]table; the file stays hand-authored and the source of truth, and a package pin is one grep-able line, never a hidden resolution. - Extends 📝 ADR-0009 —
[stable.*]joins the global-only list, because a cloned repository must never expand what's trusted.sourceis deliberately not on that list: naming an already-installed pin is a value reference, not a trust declaration, and it resolves to nothing if the pin was never installed on that machine (see Resolution). - Extends 📝 ADR-0030 — reuses its shape wholesale: a named external
source with declared visibility, a managed clone the daemon never
fetches, explicit
update/sync, and the "global only, because a cloned repository must not redirect trust" rule. The two mechanisms differ in what's being shared (a full, third-party-authored harness definition and its bundled skills, vs. Harness's own PR-reviewed, distilled skill text) and in their review gate (a human reading an install-time diff, vs. blind-reconstruction verification before a merge) — deliberately not unified into one mechanism, per Decision 3 above. - Related 📝 ADR-0008 — the security baseline this feature must not weaken: local trust, no daemon-side secret retention, least privilege.
- Related 📝 ADR-0011 / 📝 ADR-0039 — a package selects an existing adapter by
the
harnesskey and supplies only the value keys 📝 ADR-0039 already closes over; it never defines an adapter. Bundled skills add a fifth, lowest-precedence tier to SPEC-0006's merge order. - Related 📝 ADR-0023 — the closed-template, no-shell, fenced-untrusted-text discipline this ADR's manifest restrictions and content-scan-and-confirm flow both generalize from.
- Related 📝 ADR-0024 — a sibling "installs something onto a host" concept
(Harness itself and first-party infra bundles, pinned by digest) this ADR
deliberately does not extend; its converge-rule vocabulary (never rewrite
a file it didn't create, diff before applying a changed answer) is reused
for how
harness agent installtouchesharness.toml. - Related 📝 ADR-0029 — the "no supply-chain opt-in" house rule, restated here for a fourth global-only table.
- Related 📝 ADR-0038 — the manifest's forbidden-key list is exactly this ADR's secret-reference grammar.
- Governs SPEC-0026.
- Deferred: cryptographic signing or provenance attestation beyond
commit-SHA pinning (Sigstore/GPG); a first-party "official" stable tier with
any different scrutiny than a third-party one — explicitly rejected as a
permanent design stance, not merely deferred, because no tier should ever
make a human trust an install without seeing it; fleet-wide
auto-registration of a package's bundled prompts; packaging scheduled jobs
(
[job.*]) or MCP servers ([mcp.*]) as installable units.
Amendment (2026-10-09)
A package can suggest; only the operator schedules.
The original manifest rule, "a package cannot schedule itself", rejected
schedule, triggers, workdir, restart, restart_delay and
operating_hours by name, and never admitted timeout, on_overlap or
catch_up at all, so that installing a package could never make anything
run. That property is right and this amendment keeps it. Its cost was that
every operator had to invent a schedule, a timeout and a workdir from a
README, when the package's author already knew sensible values and had no
way to offer them
(harness#931).
The invariant is restated, not weakened: installing never makes anything
run without an operator choice. What changes is who may propose the
values. A package may now carry a [defaults] table of suggested
supervision values. Nothing in it takes effect on its own: config load
never applies it, and a suggested value reaches a harness only when the
operator chooses it at install or upgrade. At that point it is written onto
the operator's [harness.<name>] table as the operator's own key:
indistinguishable from one they typed, hand-editable, and attributed
local by harness describe.
Amendment decision — how a package offers supervision values
- Option A — Keep the ban (the original rule). Suggestions live in the package README.
- Option B — Let
[harness]carry the supervision keys, applied at config load like every other package value, with local keys overriding. - Option C — A display-only
[defaults]table: shown byinfoand install, never written; the operator copies what they want by hand. - Option D — A
[defaults]table the operator is asked about at install, each accepted value written onto the table as the operator's own key, with--yesinert and a separate, explicit--accept-defaults. - Option E — Option D, but
--yesaccepts the defaults too.
Chosen: Option D.
Option A — Keep the ban
- Good, because there is nothing new to build or to get wrong.
- Bad, because it pushes the author's knowledge through prose the operator must transcribe, unvalidated, into TOML: a typo'd cron line fails the next load instead of failing at install, where the operator is already looking.
Option B — Supervision keys in [harness], applied at load
- Good, because it is the least code: the keys merge exactly as
modeldoes. - Bad, because a package would schedule itself. The moment the pin is written and the daemon reloads, a cron firing or a webhook binding runs an agent nobody chose to run, which is the exact property this ADR exists to hold.
- Bad, because an upgrade could move a schedule, arm a trigger or lift a timeout under the operator's feet; REQ-8's review would have to treat every supervision key as a security-relevant change.
Option C — Display-only suggestions
- Good, because nothing is ever written that the operator did not type.
- Neutral, because the values could still be validated at manifest load.
- Bad, because it keeps most of Option A's friction (the operator still
hand-edits
harness.tomlafter every install) for no safety gain over Option D, where nothing is written without an explicit choice either.
Option D — Prompted defaults, written as the operator's keys (chosen)
- Good, because the author's knowledge reaches the operator validated, and the operator decides each value with it in front of them: keep, override, or skip.
- Good, because an accepted value becomes an ordinary local key, so config load, REQ-7's precedence and REQ-8's review need no new concept: there is no third tier of "suggested by the package, accepted by the operator" values to track, attribute or diff.
- Good, because
[defaults]is inert at config load: a daemon that loads a pin validates the table with the rest of the manifest and applies none of it, so the daemon's surface does not grow. - Bad, because once accepted, a value no longer remembers it came from the package. A later change to the suggestion cannot update it. That is by design, but it means an operator who wants the author's new timeout takes it by hand (see Upgrade below).
- Bad, because it is one more prompt in an install that already asks for a confirmation, and possibly a retype.
Option E — --yes also accepts defaults
- Good, because one flag gives a one-command unattended install.
- Bad, because
--yesalready appears in bootstrap scripts and dotfile installers. The day a package author adds[defaults], every one of those scripts would start arming schedules and binding triggers without anyone changing a line: the package would have scheduled itself, through a flag that was typed before the suggestion existed. - Bad, because it conflates two different questions (see below).
Why --yes stays inert
--yes is a consent shortcut for the trust review: "I have seen, or I
accept unseen, what this content is." It answers a yes/no about safety, and
REQ-4/REQ-5 already limit it: it never clears a high finding or an
mcp_allow write request. A default answers a different question: what
should run, and when. That is a choice with a value, not a consent, and
folding it into --yes would let a flag that means "skip the prompt" decide
that an agent fires every morning. So --yes never accepts a default. It
leaves every suggested key unset and prints each default it skipped, so the
operator learns what was on offer. Accepting defaults takes its own flag,
--accept-defaults, which names exactly that act.
The [defaults] table
[defaults]
schedule = "CRON_TZ=UTC 30 9 * * *"
timeout = "45m"
on_overlap = "skip"
workdir = "~/sweeps/pr-reviewer" # the operator's path, never a package path
- Allowed keys are the supervision keys a package could not set before,
except the secret ones and
enabled:schedule,triggers,timeout,on_overlap,catch_up,operating_hours,restart,restart_delayandworkdir. Any other key under[defaults]is a load error naming it. - Never allowed:
env_fileandsecrets_env, because a value the operator accepts with one keystroke is still a path into the credential-smuggling hole 📝 ADR-0038 closed; andenabled, because a package must never start itself (open question 2, below). - The parsers config load uses validate each value at manifest load:
the cron grammar
scheduleuses (cron.ParseStandard, with itsCRON_TZ=/TZ=prefix and@hourly-style descriptors), Go durations fortimeout(non-negative,"0"for no limit), theon_overlapandrestartenums, a non-negative integer of seconds forrestart_delay, a boolean forcatch_up, the SPEC-0012 operating-hours grammar (hours.Parse), and thechannel.<name>/webhook.<name>reference shape fortriggers. A bad value fails the manifest load naming the key, so a broken suggestion failsinfo,install,stable lintand a config load of an installed pin alike, not only on the day someone accepts it. The${ban covers[defaults]like every other manifest string. workdiris the operator's path, not a package path. It never resolves against the pin directory; it is a suggestion for the operator's table, so REQ-3's "paths stay inside the package" rule does not apply to it. Install does not create the directory.- Cross-key rules are checked on the table, not the manifest. Whether
timeoutis legal depends on whether the harness is triggered; whetherscheduleis legal depends on the operator'senabledandoperating_hours. Install validates the table it is about to write with the config loader itself, and skips a default that would make the file fail to load, naming the loader's error. A written table always loads.
Install
- Interactive: after the trust gate passes, each default is offered in a fixed order: keep it, override it (the typed value is validated by the same parser, and asked again on error), or skip it. A key the table already sets is never offered: the operator's value wins, and a re-install does not ask again.
--yes: accepts nothing, and lists the skipped defaults.--accept-defaults: accepts every default without prompting, subject to the trigger rule and the table check above. It combines with--yesfor an unattended install; on its own it leaves the ordinary confirmation in place.- Triggers are accepted only when every named source already exists as
a
[channel.*]or[webhook.*]table in the operator's global config. A package can name a source; it can never create one, because a source holds a listener, a credential and an allowlist that are the operator's to set. A default naming a missing source is skipped, naming that source, and the rest of the install continues. - Chosen values are written onto
[harness.<name>]besidesource, by the same editor and in the same write, so a declined or refused install writes none of them.
Upgrade
An upgrade compares the old pin's [defaults] with the new pin's. It
never changes a value already on the table: whether the operator typed
it or accepted it at install, it is theirs. A new or changed default is
reported. It is offered (interactively, or taken by --accept-defaults)
only for a key still unset on the table. A default the operator skipped
earlier is not offered again unless the package changes it, so a skip is a
choice and not a question asked on every upgrade. Because a default is inert
at load, a changed default is not part of REQ-8's effective-value review and
never on its own makes upgrade --yes refuse.
Open questions, answered
- Does
--accept-defaultson an unattended install count as an operator choice? Yes. The flag is explicit, names exactly the act it performs, and does not ride on--yes. An operator who writes it into a script has chosen to take the author's suggestions for that pin, and the install prints every value it wrote. What would not be a choice is a flag that predates the suggestion, which is why--yesstays inert. - Is
enabledever suggestible? No.enabledis autostart intent (SPEC-0003). A package that could suggest it would be one keystroke, or one--accept-defaults, away from starting itself on every boot. A resident harness is started by the operator. A scheduled or triggered harness needs noenabled(SPEC-0008 and SPEC-0014 reject it there), so nothing a package legitimately wants is lost.
Consequences of the amendment
- Good, because an author's tested schedule, timeout and overlap policy reach the operator validated, at the one moment the operator is already reading the package.
- Good, because the daemon gains nothing:
[defaults]is validated with the manifest and ignored by resolution. A package-sourced harness with no accepted defaults resolves identically before and after this amendment. - Bad, because the manifest grows another table, and its key list must
track the config schema: a supervision key added to
[harness.*]later is not suggestible until it is added here deliberately. - Bad, because the trigger rule makes the outcome of an unattended
--accept-defaultsdepend on what else is in the operator's config. The output names each skipped trigger and its missing source, so the difference is visible.
Confirmation of the amendment
SPEC-0026 REQ-3 (the [defaults] table, its keys, validation and the
still-forbidden keys), REQ-6 (prompting, --yes, --accept-defaults, the
trigger rule, writing the operator's keys) and REQ-8 (reporting changed
defaults, never overwriting the operator's value) carry the testable
requirements. Acceptance tests that matter:
- Each
[defaults]key with an invalid value fails the manifest load, naming the key;[defaults]declaringenabled,env_fileorsecrets_envfails, naming the key. - An interactive install keeps, overrides and skips a default, and the table holds exactly the kept and overridden values.
install --yeswrites no default and lists each one it skipped;install --yes --accept-defaultswrites them all.- A trigger default naming a source the global config does not declare is skipped, naming the source, and the install completes.
- A config load of a pin whose manifest carries
[defaults]produces a harness identical to the same pin without it. - An upgrade whose new pin changes a default leaves the operator's value for that key byte-identical.
Related Artifacts
Direct relationships declared in YAML frontmatter (per the SDD plugin's artifact-graph convention). Run /sdd:graph chain ADR-0044 for the transitive view.