Skip to content

Declared harness settings

A harness — Hermes, OpenClaw, Claude Code, Codex, OpenCode, Pi — keeps its own configuration, in its own file, in its own shape. AgentPod does not own that file. What it can do is let the fleet declare what a registered setting should be, write that declaration to a station through a reviewed operation, and tell you where each station’s actual value stands against what was declared.

Terminal window
fleet config set hermes.approvals.mode --value strict

This records a declaration. It does not touch a station. Nothing on disk changes when this command returns — the hub remembers that the fleet wants hermes.approvals.mode to be strict, at whichever level you declared it. Writing a harness’s own config file is a separate, reviewed operation — plan, then apply — described below in Writing a declaration.

The gap between declaring and applying is deliberate, not a missing feature. A harness persists an operator’s own decisions into the same file a declaration targets — see additive-only below — so set itself should never move a file unasked, and nothing reconciles on a timer either: a sweep running on a tick would race the harness or silently undo what an operator just changed through it. A user who treats set as something that changed a machine has been misled by it; treat it as writing a line into a policy document, not into the harness. Only apply, naming the exact plan a human reviewed, writes anything.

Terminal window
fleet config unset hermes.approvals.mode

removes a declaration. Undeclaring a level that has nothing declared on it is a no-op, not an error.

A declaration targets exactly one level: a station, a node, or the whole fleet (both null). Levels resolve with station the most specific and fleet the least — a station-level declaration wins over a node-level one, which wins over a fleet-wide one, for the same setting.

Terminal window
fleet config set hermes.approvals.mode --value strict # fleet-wide
fleet config set hermes.approvals.mode --value strict --node nod_123 # one node
fleet config set hermes.approvals.mode --value strict --station st_abc # one station

A setting also carries its own scope — profile, project, or user — which says where it actually lives inside the harness’s document, independent of which level you declared it at.

A single --value is declared as a string. The declaration is stored as JSON and the node refuses a value whose shape its registry does not expect, so the shape has to be right here rather than at write time. For a list-valued setting — every additive-only setting is one — repeat --value once per entry, or use --json for the value exactly as JSON:

Terminal window
# a list, by repeating --value
fleet config set hermes.approvals.command_allowlist --value "git status" --value "ls"
# a ONE-entry list, a number, a boolean — the forms repeating cannot express
fleet config set hermes.approvals.command_allowlist --json '["git status"]'
fleet config set hermes.approvals.timeout --json 900

--value and --json are mutually exclusive, and set needs one of them. Declaring a list-valued setting with a single --value stores the string it is, and every later plan for that station is then refused SHAPE_UNEXPECTED — so if a plan says a declared value is not a list of strings, this is why.

Why a station is not a config scope, except for Hermes

Section titled “Why a station is not a config scope, except for Hermes”

For five of the six harnesses, a station is a project path, while the harness’s configuration lives per user. Declaring a setting “for this station” would be declaring it for a document the station doesn’t actually own — the user’s config file is shared across every project that user opens, so a station-scoped declaration on, say, Claude Code or Codex has no document to land in.

Hermes is the exception: there, a station is a profile, and a profile is exactly the document Hermes’s settings live in. That is the only harness where declaring a setting at the station level corresponds to a real, single document.

--station is accepted for any setting, including one whose registered scope is not profile. The declaration is stored; nothing is refused at set time. The mismatch surfaces later, when the declaration is read back — every comparison for that station reports the setting as out-of-scope, with a reason naming the scope, instead of matches, drifted or absent. So a --station declaration of a user- or project-scoped setting is not an error you are told about when you make it; it is a state you see the next time you ask where things stand.

Every registered setting carries a policy, which says what this system is permitted to do toward making a station’s value match the declaration:

Policy Meaning
reconcilable The value can be fully reconciled to the declaration — whatever is there can be replaced outright.
additive-only The declaration is a floor, never the whole value. Items can be added; nothing already there is ever removed.
report-only The value is only ever compared and reported. Nothing here writes it.

additive-only exists because a harness persists an operator’s own decisions into the same file a declaration targets. hermes.approvals.command_allowlist is the first example: an operator can extend that allowlist from Hermes’s own UI at any time, and that edit lands in the same document a fleet-wide declaration points at. Reconciling the list down to exactly what was declared — the reconcilable behaviour — would silently delete a grant the operator made minutes earlier through a surface this system never watched. A declared baseline must never be able to erase an operator’s own decision; additive-only is the policy that guarantees that, by only ever being allowed to add.

apply enforces exactly this at write time, not just at comparison time: a reconcilable write replaces the key outright; an additive-only write only appends items that are not already present, so re-applying the same declaration twice is always safe; and a report-only setting is planned as a no-op and is never sent to a node to write — by design, not because writing it hasn’t shipped yet.

Terminal window
fleet config settings

lists every setting any reachable node currently manages — the live registry, read from the node, never a copy held in the hub. A node that cannot currently be asked contributes nothing to this list, and is named separately so its absence isn’t silent.

This registry currently covers seven settings — plus whatever else is live on the node you ask:

Setting Scope Policy
hermes.approvals.timeout profile reconcilable
hermes.approvals.mode profile reconcilable
hermes.approvals.command_allowlist profile additive-only
hermes.plugins.enabled profile additive-only
hermes.plugins.stream_reasoning_deltas profile reconcilable
hermes.skills.external_dirs profile additive-only
openclaw.hooks.allowConversationAccess user reconcilable

The last four are the same four settings the apn hermes-live, apn hermes-skills and apn openclaw-errors verbs have always managed. Folding them into this registry did not replace those verbs, and does not retire them: both paths call the exact same writer, so a plan fleet config derives for one of these settings and the edit the matching apn command would make by hand are byte-identical, not merely equivalent. Run apn hermes-live, apn hermes-skills, or apn openclaw-errors directly whenever that is more convenient — nothing about declaring these through fleet config changes what those commands do.

hermes.plugins.enabled and hermes.plugins.stream_reasoning_deltas share one writer, and it is indivisible: Hermes only reports reasoning deltas from a plugin that also has plugins.enabled set, so declaring either setting enables the agentpod-live plugin as part of the same edit. Declaring hermes.plugins.stream_reasoning_deltas alone still touches plugins.enabled — both key paths are named in the plan’s entries, and the plan’s diff shows every line that moves, so this is visible to whoever reviews the plan before apply ever writes anything. It is not a silent side effect; it is one edit covering two keys, and an operator declaring one setting should expect the other key to move too.

Separately, hermes.plugins.enabled can only ever add that one plugin, agentpod-live. Declaring any other plugin name is refused by name (SHAPE_UNEXPECTED) rather than appended — the setting id reads as if it manages Hermes’ whole plugin list, but the writer behind it only knows how to turn on the one plugin this system ships.

Terminal window
fleet config show # every declaration in the fleet, as stored
fleet config show --node nod_123 # one node's declarations, as stored
fleet config show --station st_abc # one station: declared vs observed, compared
fleet config drift # every station whose value differs from what's declared

Only --station compares anything. With no flag, and with --node, show returns the declaration rows themselves — what was declared, at which level, by whom — and no station is contacted: there is no observed value and no state in that answer. That is not a gap in the output, it is what the question means at those levels: a fleet-wide or node-wide declaration is one row that may apply to many stations, and comparing it requires naming which station you mean. --station names one, so that form asks its node for the current values and reports a state per setting. fleet config drift is the other compared form, across every station at once.

Each comparison — show --station and drift — reports, per setting, a state:

State Meaning
matches The observed value agrees with what’s declared.
drifted Declared and observed disagree.
absent Declared, but the key isn’t in the document at all.
opted-out An operator explicitly opted this station out of the declaration — see Opting a station out.
awaiting-restart A value was written but the harness hasn’t picked it up yet — see awaiting-restart.
unreadable The document couldn’t be read, or the key is there but holds a shape this release can’t read — a nested map (see below). Never reported as matches — an unreadable value is not evidence of agreement.
out-of-scope A per-station declaration was made for a setting whose registered scope isn’t the station’s document (see above).

Each comparison also names the level the winning declaration resolved from — station, node, or fleet — the same precedence Scopes and levels describes. A station with its own declaration, one inherited from its node, and one inherited from the fleet all report differently here even when the declared value happens to be the same, so you can tell which declaration is the one actually governing this station without re-deriving precedence by hand.

fleet config drift only reports states other than matches, plus the list of stations it could not reach at all — a station it couldn’t ask is never silently left out of the total.

That list is narrower than “every station that is down”, and deliberately so. It holds the stations that had something declared for them and whose node could not be asked. A station with no declaration resolving to it is never contacted in the first place, so it appears neither in the observations nor in the unreachable list, however offline it is; and a station that does not advertise config.manage at all is not a candidate for this command. Read the list as “declarations I could not check”, not as a fleet health report — fleet nodes is where you see what is up.

This release reads scalar and list values. A registered setting whose key holds a nested map is reported unreadable, with a reason saying so — not absent, and not drifted.

hermes.approvals.command_allowlist is a list in every real document, and reads normally: a block list, an inline list, and an empty list all come back readable, with the list itself as the observed value. It stays in the registry even on the shape it can’t read — the alternative was to report a key that is plainly in the document as “not in the document at all”, which is a false sentence, or to compare an inline list’s raw text and report drift forever. An honest unreadable with a reason is the state that tells you what is actually true.

opted-out names an operator’s explicit choice, and it outranks the ordinary comparison — an opted-out setting is reported opted-out even when the observed value differs from the declaration, never drifted, because drifted would invite the exact apply the opt-out exists to prevent.

The opt-out lives in a hub-side register (harness_config_opt_out) — not as a key inside the harness’s own config file. A harness like Hermes rewrites and migrates its own config document on its own schedule; a key this system invented there would be a key whose survival this system does not control, so the opt-out is kept somewhere this system does control instead.

A harness’s own opt-out is a second, distinct source of opted-out

Section titled “A harness’s own opt-out is a second, distinct source of opted-out”

A harness can also carry its own record that an operator turned a setting off, made through the harness’s own UI rather than through agentpod at all:

  • Hermes: the plugin named in plugins.disabled.
  • OpenClaw: plugins.entries.agentpod-errors.enabled explicitly false.

Either one reports opted-out, with a reason naming the harness as the source — distinct from the hub-register reason, so you can tell “I exempted this through fleet config” from “I disabled this inside the harness itself” by reading the reason text alone. And either one now refuses a write, not only a comparison: declaring hermes.plugins.enabled while plugins.disabled names agentpod-live is refused OPTED_OUT, the same as an operator opt-out recorded in the hub’s own register.

One asymmetry, stated plainly rather than papered over. For Hermes, the check lives inside the one writer both apn hermes-live and the registry call, so both paths are protected — running apn hermes-live enable by hand against a profile where the plugin is listed in plugins.disabled refuses exactly as a fleet config apply would. For OpenClaw, the check lives only in the registry’s own plan derivation, not inside the writer apn openclaw-errors and the registry share. That means apn openclaw-errors enable --apply, run by hand, does not check OpenClaw’s own opt-out and can still re-enable a plugin an operator disabled through OpenClaw’s own UI. This is pre-existing behaviour, not something this feature introduced, and it is a known follow-up rather than something closed here.

compare(), adopt-time reconcile, and plan/apply all honor a row in this register the moment one exists — there is no path left that writes an opted-out setting silently. plan excludes an opted-out setting from what it asks the node to plan and names it in a refused list alongside whatever it did plan (a mixed request — some settings opted out, the rest not — refuses only the opted-out ones, never the whole plan: fleet config plan --station ID plans every setting currently declared for a station with no way to narrow it, so a whole-plan refusal would let one opt-out block writing anything else to that station). apply re-checks the register too, independently of plan — an operator can opt a station out after a plan was reviewed and before someone applies it, and that window is exactly where the check matters most. Because one apply call writes one journaled plan as a single atomic operation, a setting opted out after its plan was made refuses the whole apply, naming the setting; nothing in that plan is written, and a fresh plan (which will exclude the now-opted-out setting) is the remedy.

An opt-out stops this system writing a setting from here on. It does not revert what is already in the file. A station that was already drifted, or that already carries a value written before the exemption existed, stays exactly as it is — opting out changes what compare() reports and what plan/apply will do next, never what is already on disk.

An exemption targets exactly one of two levels — a station, or a node (every station on it, unless a station overrides it; see below). There is deliberately no fleet-wide exemption: fleet config unset already means “nothing is declared for this setting, anywhere” — a fleet-level opt-out row would only ever be a second way to say something unset already says, and a second way to say one thing is how a refusal becomes ambiguous. A row naming neither a station nor a node, or naming both, is refused before it is stored: the CLI requires exactly one of --station/--node, the route validates the same rule, and the table’s own CHECK constraint keeps it true for any other caller too.

A station-level row always beats a node-level one, for the same setting: when the station has its own row — whichever way it reads — that row decides outright and the node row is never consulted. Only when the station has no row at all does the node’s row apply.

This is the subtle half of the feature, and the two are not interchangeable:

Terminal window
fleet config opt-in hermes.approvals.mode --station st_abc

records a row for st_abc with optedOut: false — “this station is explicitly NOT exempt.” That row is not the absence of a row, and it stops the search right there: a station row decides outright, true or false, without the node row ever being read. So opt-in at the station overrides opt-out at the node — if nod_123 is opted out of a setting and st_abc is one of its stations, fleet config opt-in ... --station st_abc pins st_abc back under ordinary reconciliation while every other station on nod_123 stays exempt.

Terminal window
fleet config opt-out hermes.approvals.mode --station st_abc --clear

does something different: it forgets the row entirely, rather than recording a false one. With no station row left at all, the search falls through to the node — so if nod_123 is still opted out of that setting, st_abc becomes exempt again too, the moment its own row is gone. --clear works at the node level the same way: a cleared node row leaves any station with no row of its own under no exemption at all, since there is nothing left to fall through to.

Use opt-in to pin one station in despite its node. Use --clear to stop having an opinion at that level and let whatever the other level says take over.

Unlike set, show, plan, inspect and apply — whose --station is the station’s database row id — opt-out and opt-in take --station as the station’s stationKey. That is deliberate: a stationKey survives unadopt and re-adopt (re-adopting a station gives it a new row). An exemption recorded against a stationKey is still there, and still honored, after the station it names is unadopted and re-adopted; one recorded against a row id would silently stop applying the moment that happened.

Terminal window
# exempt every station on a node while a new default is piloted
$ fleet config opt-out hermes.approvals.mode --node nod_123 --reason "piloting a new default"
# pin one station on that node back in, despite the node-wide exemption
$ fleet config opt-in hermes.approvals.mode --station st_abc
# see the register, narrowed to that station
$ fleet config opt-out --station st_abc
[
{
"id": "cfgoo_9f1c3a7b",
"stationKey": "st_abc",
"nodeId": null,
"settingId": "hermes.approvals.mode",
"optedOut": false,
"reason": null,
"optedOutBy": "usr_4b2e",
"createdAt": "2026-10-05T09:00:00Z",
"updatedAt": "2026-10-05T09:00:00Z"
}
]
# change your mind: let st_abc fall back to following its node again
$ fleet config opt-out hermes.approvals.mode --station st_abc --clear
{"cleared": true}

(Illustrative, not literal output; tenantId is present on every row but omitted above for brevity.) After the final --clear, st_abc has no row of its own, so it inherits nod_123’s exemption again — it is now opted out for the same reason nod_123 is, not because of anything recorded against st_abc itself.

With no SETTING_ID, fleet config opt-out lists the whole register, unresolved — every row, at either level, with its reason and who set it; --station KEY or --node ID (never both) narrows that listing. That is the register as stored, the same sense in which fleet config show with no --station returns declaration rows rather than a comparison. To see what a specific station is exempt from right now, after station-beats-node resolution, read its opted-out states from fleet config show --station ID or fleet config drift instead.

The register is visible on a station’s own page in the console (see Seeing and applying it from the console) — but fleet config opt-out / opt-in remains the only surface that writes it.

Writing a declaration: plan, inspect, apply

Section titled “Writing a declaration: plan, inspect, apply”
Terminal window
fleet config plan --station ID the edit that would be made, and its digest
fleet config inspect --station ID --operation ID a plan already made, as it was reviewed
fleet config apply --station ID --operation ID --plan-digest SHA256

plan asks the hub to derive the exact edit against the station’s current document and returns a ConfigPlan: the before/after diff, which action each setting takes (create/modify/append/noop), whether a restart is needed, and a planDigest — a SHA-256 over everything in the plan except the timestamp and operation id, so two plans of an unchanged document always digest the same and a plan drawn against a changed document never does. inspect reads back exactly the plan or receipt a station’s own node journaled for that operation — it never re-derives anything. apply is the only verb that writes, and it refuses to run without --plan-digest: that digest must be the one plan printed for this operation, so a human reviewed the exact edit being written rather than whatever the current plan happens to be by the time apply runs.

create covers a key the document does not have yet, including the case where its whole top-level section is absent — the ordinary state of a freshly adopted station whose operator has customised nothing. That section is appended at the end of the document, holding only the declared key, so no line already there is moved, reindented or reflowed. A section that is there but holds something this editor will not extend — a scalar, a bare key with nothing under it, a list, or an inline {...} mapping — is refused SHAPE_UNEXPECTED and named, never rewritten. Only one level of section is ever created.

fleet config plan --station ID plans every setting currently declared for that station, at whatever level resolves to it — there is no flag to narrow the plan to one setting. A setting the station is opted out of is left out of that plan and named in refused instead (see Opting a station out) — the rest of the declared settings still get planned.

A worked example, declaring and then writing hermes.approvals.mode for one station:

Terminal window
$ fleet config set hermes.approvals.mode --value strict --station st_abc
$ fleet config plan --station st_abc
{
"schemaVersion": 1,
"operationId": "cfgop_3f9a2c7b1e6d48a09c21",
"stationKey": "st_abc",
"entries": [
{
"settingId": "hermes.approvals.mode",
"file": "/home/agentpod/.hermes/profiles/st_abc/config.yaml",
"keyPath": "approvals.mode",
"policy": "reconcilable",
"current": "standard",
"intended": "strict",
"action": "modify",
"restartToTakeEffect": true
}
],
"beforeSha256": "1b80374ee731c363440504e674caf2d26d72ee818a2482571e6beff6de397d5a",
"diff": "- mode: standard\n+ mode: strict\n",
"diffTruncated": false,
"noOp": false,
"restartRequired": true,
"createdAt": "2026-10-05T09:12:03Z",
"planDigest": "c68e0c65d2a58aa481e8fdf83422dbb4712a8b97e41fb907bdb3109adc503077"
}
$ fleet config inspect --station st_abc --operation cfgop_3f9a2c7b1e6d48a09c21
{ "plan": { ... as above ... }, "phase": "planned", "updatedAt": "2026-10-05T09:12:03Z", "written": [] }
$ fleet config apply --station st_abc --operation cfgop_3f9a2c7b1e6d48a09c21 \
--plan-digest c68e0c65d2a58aa481e8fdf83422dbb4712a8b97e41fb907bdb3109adc503077
{ "plan": { ... }, "phase": "applied", "written": [{"settingId": "hermes.approvals.mode", "action": "modify", "wrote": "strict"}],
"afterSha256": "318e20b3a675cd8701af3cab0b019abbab63ca2e1183b77fa39a7e538ffb50ab", "updatedAt": "2026-10-05T09:12:41Z" }

(Operation ids and digests above are illustrative, not literal output.) On the node, the plan and its receipt are journaled in .agentpod/config-operations.json — a sibling directory next to the harness’s own config, not inside it, because the harness rewrites and migrates its own config document and a receipt that must survive that rewrite cannot live anywhere under it.

Re-deriving the plan at apply time, rather than trusting the one plan printed earlier, is what catches a document that changed in between — see refusals below for PLAN_STALE and PLAN_DIGEST_MISMATCH.

PLAN_STALE catches a document that changed before the write. A narrower window remains after it: the write itself lands, and then, in the moment between that write and the read-back that confirms it, something else changes the same file again — an operator’s own “Allow always” through Hermes’ UI, say, landing a heartbeat after apply wrote its own edit.

When that happens, this system does not pick a winner. The document on disk is left exactly as the read-back found it — whatever landed there in that window is not overwritten, and the edit apply itself intended is not thrown away either. It is written instead to a sidecar file beside the original, named by appending .agentpod-rejected to the document’s own filename — config.yaml gets config.yaml.agentpod-rejected, openclaw.json gets openclaw.json.agentpod-rejected. The operation’s receipt (fleet config inspect) records "phase": "conflict" with an error naming both paths and stating plainly that neither edit was lost.

That sidecar is bookkeeping for a human, not for this system: a later lost race against the same document overwrites whatever an earlier one left there, and nothing here ever reads a sidecar back — not on a retry, not on the next plan. Reconciling the two files, if they need reconciling at all, is a manual step. A fresh fleet config plan against the document as it now stands is the way forward; it has no reason to know the sidecar exists.

awaiting-restart: written, but not yet in effect

Section titled “awaiting-restart: written, but not yet in effect”

A setting whose policy requires a restart to take effect — every registered Hermes and OpenClaw setting today — is reported awaiting-restart right after apply writes it, until the harness’s gateway actually restarts. AgentPod will not restart the harness for you. Nothing in this system issues a restart; an operator restarts the gateway, from wherever they already do that, and the state changes on its own once observed.

The decision is made by comparing a gateway process id, not by a timer. Hermes and OpenClaw both multiplex one gateway process across every profile or subagent on a host, so that process’s pid is exactly the one that re-reads config. apply reads the station’s health right after a successful write and records that pid. The next comparison reads the station’s current gateway pid and reports awaiting-restart whenever that pid is unchanged from the one recorded at write time — or when either pid cannot be confirmed at all: the current one (health degraded, harness stopped) or the one that was supposed to be recorded at write time, which is left unset whenever that post-write health read timed out or came back in an unexpected shape. An unconfirmable pid on either side deliberately stays awaiting-restart rather than resolving to matches: claiming a restart is still needed when it already happened costs one needless restart; claiming a restart already happened when it hasn’t produces false agreement, which is the worse mistake.

Whether hermes.approvals.* truly needs a restart to take effect is itself unverified — Hermes’s own documentation lists hot-reloadable settings and settings needing a restart, and approvals.* is in neither list. The registry assumes true deliberately: an unnecessary restart is cheaper than a running gateway that silently disagrees with its own config file. See docs/superpowers/notes/2026-10-05-hermes-approvals-restart.md for what was checked and why it stayed inconclusive. hermes.plugins.* and hermes.skills.external_dirs are not the same kind of guess: Hermes reads a profile’s plugin and skill configuration when its gateway starts, which is what apn hermes-skills itself already tells an operator after writing, so RestartToTakeEffect for those three is a documented fact rather than an assumed one.

Everything above has a CLI-free path too, on a station’s own page — its Configuration tab, which also holds that station’s plugins and its skills, because on a Hermes station all three write the same configuration document. A declared configuration panel sits at the top of that tab whenever the station advertises the config.manage capability; a station that does not advertise it shows nothing in its place, not an empty panel, and a station with nothing configurable at all has no Configuration tab.

A link to the older ?tab=skills still works — it lands on the Configuration tab.

The panel lists one row per registered setting with something declared for that station: the declared value and the level it came from (station, node, or fleet), the observed value, and the state — all seven: matches, drifted, absent, unreadable, out-of-scope, opted-out, awaiting-restart, each with its reason shown alongside it. It is read-only for exactly as long as there is nothing to review: a “Review changes” button asks the hub for a plan over this station’s declared settings, the same plan a fleet config plan call would get back — the file, the key path, current → intended, the action, and the diff, plus the plan’s own digest. Nothing is written at that point. “Apply reviewed plan” sends that operation id and that exact digest, never a digest re-fetched or re-derived at apply time — the console goes through the identical reviewed-plan-then-digest discipline the CLI’s plan/apply pair enforces, against the same routes, because the digest is the record that a human saw the edit before it was written. A plan that refuses some of the requested settings (an OPTED_OUT entry mixed in with plannable ones, say) names each refused setting, its code and its message, beside whatever the plan did manage to derive — a partial refusal never quietly disappears the rest of the plan.

The console never restarts anything, here or anywhere else. A row reading awaiting-restart says so plainly, and if a restart is needed to apply what you are about to review, the panel says that too, before the apply button, not after. Use the station’s own restart control (reached from this same panel when the page wires one up) once you are ready.

Exemptions are visible here, not editable. An opted-out row shows which level the exemption came from, who recorded it and why, when it rode in through agentpod’s own register — and it reads differently again when the harness’s own record is the source (Hermes’ plugins.disabled, OpenClaw’s plugins.entries.agentpod-errors.enabled: false), never conflating the two. Recording or clearing an exemption is still only fleet config opt-out / fleet config opt-in — the console does not write this register, by design: reading it is what made the opted-out state legible, and giving it its own write surface here was ruled out rather than added silently.

None of this replaces the raw config editor reached from the file browser’s own “Edit” action on an open file. That editor is unchanged, and it is still the only way to touch anything in a harness’s config file that is not a registered setting — this panel sits beside it, for exactly the settings the registry knows about, and absorbs none of its job.

Adopting a station: one reconcile, not a sweep

Section titled “Adopting a station: one reconcile, not a sweep”

When a station is adopted, every setting already declared for it — at station, node, or fleet level — is reconciled exactly once, right after the station’s row is written. A setting is skipped, not written, when its policy is report-only, when it’s opted out, or when it already matches; otherwise it’s planned and, if the plan isn’t a no-op, applied with that plan’s own digest — the same plan-then-apply path described above, run on the station’s behalf.

A write failure here never fails the adoption. Every setting on every station is isolated: a plan refusal, an apply refusal, an unreachable node, or an unreadable document is recorded against that one station (visible as its config reason) and never thrown. A station adopted with one setting unwritten is better than one not adopted at all.

And it never holds the adoption open for long. Stations are reconciled concurrently rather than one after another, and the whole pass is capped: past the cap the adoption answers, and the stations still in progress keep going and keep recording their own config reason. That matters because a node can be connected but wedged, where every round trip pays its full timeout instead of failing fast — adopting many stations onto such a node used to leave the request running long after the rows were committed, so a client that gave up reported a failed adoption of stations that were adopted.

The cap is one round trip’s worth of time, while a station’s pass makes several of them in sequence, so a merely slow node reaches the cap too — that is the ordinary case, not the exceptional one. A station still in progress when the cap fires is reported as pending, never as failed: nothing has gone wrong, the pass simply has not finished, and it records its own outcome against the station when it does. Reconcile also re-checks that a station is still adopted before it writes anything, so unadopting a station right after the adoption answers cannot leave a write landing on it afterwards.

This reconcile runs exactly once, at the moment of adoption — never on a timer and never swept across already-adopted stations. A station that drifts afterward stays drifted until someone asks for it (fleet config drift) or declares and applies again; running this on a schedule would race the harness’s own rewrites of the same file, or silently undo a change an operator just made through it.

A refusal that cannot be told from a pass is the failure this whole area keeps hitting, so every one of these is distinct and carries its own message. There are ten:

Refusal What distinguishes it
UNKNOWN_SETTING The setting id isn’t in any reachable registry — or no node could be asked to confirm either way; the message, not the code, tells you which.
NOTHING_DECLARED The setting id IS in the station’s live registry, but nothing is declared for that station at any level and no value was given to plan against. Remedy: fleet config set. Distinct from UNKNOWN_SETTING because the id is real and the remedy is a different command.
NODE_UNREACHABLE The node could not be asked at all (offline, timed out, disconnected, or answered in an unexpected shape). Remedy: retry once it is back. Never used for a refusal the node itself named — a node that answers with a refusal is a node that was reached.
OUT_OF_SCOPE The declaration’s own target has no document to land in at all (for example, declaring against Hermes’s composite root, which has no profile-scoped document of its own) — a plan-time refusal, distinct from the out-of-scope comparison state reported for a per-station declaration of a non-profile-scoped setting.
SHAPE_UNEXPECTED The declared value doesn’t fit the setting’s registered shape (an additive-only value that isn’t a list of strings, say), or the derived edit would change something outside the keys this plan claims to touch.
PLAN_STALE The document changed after this plan was reviewed. Remedy: re-plan and re-review.
PLAN_DIGEST_MISMATCH The digest apply was given does not match the one this station’s journal recorded for that operation. Remedy: re-read the plan that was actually reviewed (inspect), not re-plan — the document itself hasn’t necessarily changed.
OPTED_OUT An explicit operator opt-out (see Opting a station out). plan produces it per setting — refusing just the opted-out entries of a mixed request, or the whole request when every setting named was opted out — before config.plan is ever dispatched for one of them. apply produces it for the whole operation when a setting the plan covers was opted out after the plan was made, before config.apply is ever dispatched; nothing in that plan is written.
UNREADABLE The document could not be read or parsed at all; nothing about its contents is inferred.
CREDENTIAL_PATH The setting’s target resolves to a credential file (auth.json, .env, or a path under a credentials directory) and is refused before it is ever opened, let alone read or edited.

PLAN_DIGEST_MISMATCH was added during implementation, after the seven above were specified, because collapsing it into PLAN_STALE would make two conditions with different remedies indistinguishable from their refusal code alone. NOTHING_DECLARED and NODE_UNREACHABLE are the two the hub produces on its own: no node can produce them, because only the hub knows what is declared and only the hub knows it could not get an answer.

A refused plan is a refusal, not a plan. fleet config plan exits non-zero on one, and the HTTP route answers 400 when re-sending the same request could never work (UNKNOWN_SETTING, NOTHING_DECLARED, OUT_OF_SCOPE, SHAPE_UNEXPECTED, OPTED_OUT, CREDENTIAL_PATH — the remedy is a different request, usually fleet config set) and 409 when the request was well-formed and lost to the state of the document or the station (UNREADABLE, PLAN_STALE, PLAN_DIGEST_MISMATCH — the remedy is to re-read and re-send) — never 200 with a refusal in the body, which no script could tell from a plan. SHAPE_UNEXPECTED is a 400 because neither of its causes resolves itself: a declared value that does not fit the setting has to be re-declared, and a derived edit that would disturb the document never succeeds on a retry of the same request. OPTED_OUT is a 400 whether the hub or the node is the one that noticed. The code and the sentence are the node’s own wherever the node is the one that refused, and at adopt time they are what gets recorded against the station, so a recorded reason names the refusal rather than blaming the connection.

Declaring a setting the hub doesn’t recognize is refused with UNKNOWN_SETTING. The refusal’s reason is read verbatim from the hub — it may mean the setting genuinely doesn’t exist in any reachable harness’s registry, or it may mean no node was reachable to confirm either way. Those are two different problems with the same refusal code, and only the reason text tells you which one you have.