- matrix-rain reference is in packages/dashboard/src/home.js, not
swarm-ui — cite it correctly (dashboard package) rather than implying
it lives inside this doc's own scope.
- drop FormField from the visible component-first inventory (it has no
/components demo, deliberately — a real exception to the 'every new
ui/ component gets a demo' rule stated two paragraphs later) and note
the exception explicitly instead of leaving the contradiction.
Distills the design-language discussion on #3444 into a durable
reference: visual language, motion, theming policy, data-freshness/
refresh, error UX, empty states, layout/viewport, component-first
design, and attention — plus an open-questions table pointing at the
still-unbuilt issues (#3452/#3453/#3454/#3456) instead of asserting
them as settled.
Mirrors css-vars.md's shape and links to it + to /components rather
than duplicating either. First draft for comment, not a final spec.
Drops swarm.otel.url (a loopback default an operator had to override on a
split host) in favor of swarm.otel.domain -- the same
gateway.localNames + nginx-vhost-through-the-gateway shape every other
swarm service (authelia, grafana, victoriametrics, ui) already uses. The
hive tier's exporter now reaches it as https://<domain> unconditionally,
resolved locally by dnsmasq on a co-located host and over the real network
otherwise, instead of a config knob nobody sets until they hit the silent
drop.
Costs CA trust on the hive tier: otel.nix wires
lib/hive-ca-trust.nix's trustBundle with hostUnit = true on the
opentelemetry-collector host unit, the same flag #3441/#3442 added for
swarm-controller and hive-c0re.
mara, #3125 comment 58363: "go c".
All four sibling swarm containers import swarm-container-resolver.nix;
this one did not. It matters more here than most: otel.endpoint is an
operator-configured external hostname, and reaching it is the entire
reason this container holds a credential.
Also aligns two details with those siblings - the enable default is
asserted from swarm-required-services.nix with the metrics pair it
feeds, so that file remains the one place a service host is declared,
and machine is readOnly since its description already calls it a fact
rather than a knob.
The pre-push lint refuses them, and rightly: a comment that names an
issue number ages into a pointer at a closed thread. The constraint each
one carried is stated directly instead.
A collector serves its own metrics on localhost:8888 unless told
otherwise, and co-located tiers share a network namespace, so the second
one to start dies with 'bind: address already in use'.
The port appears in neither config - it is a default inside the binary -
so comparing the ports the configs name reports them distinct. A
behavioural probe found it by being unable to start the chain.
metrics.address is the spelling that looks right and is rejected by this
version ('migration.MetricsConfigV030' has invalid keys: address);
readers is the schema it accepts.
observability.md described a single collector holding the upstream
credential. It also said endpoint and protocol are what agents are
handed; agents get the derived first hop, which has been true since the
collector was introduced.
The swarm tier is documented beside its sibling swarm services rather
than here, and the one line an operator must not miss - swarm.otel.url
on a hive that does not run them - is called out in both places, since
leaving it unset loses telemetry silently.
The hive collector's only exporter becomes the swarm's collector, and
the upstream credential, the metrics-store exporter and the choice of
destination all move one tier up.
Its assertion goes with them: 'endpoint or a local store' was the right
rule while this tier picked the destination, and is the wrong one now.
A hive that runs no swarm services has neither, forwards to a swarm
collector elsewhere, and is correctly configured — the rule that
replaces it lives in swarm-otel.nix, where the destinations are.
The option descriptions here described a topology with one collector in
it: endpoint and protocol are not what agents are handed (they get the
derived first hop, see hive-c0re/environment.nix), and the credential is
not read by this tier.
The swarm tier is the only holder of the upstream credential, the only
writer to the swarm's metrics store, and (once #3283 lands) the place
that stamps hive= from the authenticated connection rather than from
anything a sender can choose. Today one collector does both tiers' jobs,
which works only because they land on one box.
A container rather than a second host unit, for two reasons that agree:
every sibling swarm service is one, and `services.opentelemetry-collector`
is a singleton NixOS option already spoken for on the host by the hive
tier. A container gets its own evaluation and therefore its own
collector.
Port defaults to 4319, deliberately not the OTLP default 4318 the hive
tier uses: swarm containers share the host netns, and two listeners
claiming one port is not a build failure but a runtime coin toss with
nothing in any log saying so -- the same collision grafana and the forge
hit on 3000.
`url` is an option with a co-located default rather than a loopback
literal in the exporter, so a split-host deployment is a config change
instead of a code change.
Wires nothing yet: the hive tier still exports directly, and switching it
over is the next commit.
Closes#3434.
Agent creation had no way to record which hive an agent runs on.
POST /api/agents now requires a hive, so the roster fetched off
GET /api/hives backs a required SelectField here rather than a
free-text field. Single-hive swarms auto-select their only hive;
multi-hive swarms show a disabled placeholder and force an explicit
choice.
Rebuilt on top of the #3448 form kit (merged after this branch was
originally opened): reuses TextField/SelectField/Button instead of
page-scoped input chrome, and SelectField gains an optional disabled
placeholder option (needed for the loading/empty/multi-hive states
here, generalizes cleanly for future callers). Form goes back to a
column layout per mara's earlier visual feedback on this same page
(two fields of different natural width no longer line up in a row).
mara, PR #3438 review: 'remove the identity function. agent names
are unique and all repos go into agent-configs namespace anyway'. Right --
repo==agent isn't a convention worth a name once every call site can just
say so; call create_repo/add_repo_member/seed_agent_config with &agent
directly.
Every repo in this graph is `agents/<agent>` -- the node payloads were
carrying the same string under two names, and `create_agent` opened with
a `let repo = agent.clone()` that said so out loud.
All four node kinds now carry `agent` alone, and `forge::agent_repo` is
the single home for the naming convention. The identity it returns is the
point: a caller holding an agent name never writes a repo name itself, so
changing the convention later is one edit rather than a search.
`forge::Client`'s methods keep taking a repo, because they are a general
forge client and `add_repo_member(repo, user)` is a real signature -- the
derivation belongs at the call site that knows the two are the same here,
not baked into an API that has no reason to assume it.
`data()`'s four arms are now identical and merged into one or-pattern.
Left as an explicit list rather than a catch-all so a fifth variant fails
to compile here instead of silently rendering as an agent name.
Seeding a config repo is the same work whichever hive the agent is bound
for, and an agent's config states nothing about where it runs -- so the
node has no use for the address. It stays on the request, where it is
parsed and checked against the roster, and reaches its consumer when the
node that sends a deploy message exists.
That leaves this endpoint validating a field it carries no further, which
is the intended end state: the request shape is the breaking half, and
settling it once is cheaper for every caller than doing it twice.
`data()`'s two repo-and-agent arms now share a body. Merged rather than
kept apart -- `label()` is what distinguishes the nodes to a viewer.
`POST /api/agents` now requires `hive` alongside `name`. It is parsed as
an `Ident` like `name` already was, and then checked against the roster
loaded from `SWARM_CONTROLLER_HIVES` -- a hive that is not in this swarm
is a 400 naming the ones that are, rather than a typo accepted and
forgotten. The roster check is what makes the field worth having; without
it nothing notices until a deploy message is addressed to a hive that
does not exist.
`hive` is an address, not an attribute of the agent: it is where a deploy
message goes over the queue, so nothing writes it into the agent's config
repo. A config naming its own hive would be a second statement of where
the agent lives, free to drift from the queue that actually delivers to
it.
It rides on the `InitAgentConfigRepo` node payload because the graph is
the only thing carrying the operator's choice forward from the API
boundary; seeding does not consume it. The node that routes on it is the
deploy node in #3124.
The refusal is asserted by effect -- the test checks that *nothing was
queued*, not just the status code, since a version that queued the graph
and then complained would satisfy a status-only assertion while still
creating the agent.
This is a breaking change for every existing caller: the swarm-UI create
page posts `{name}` only and needs its hive dropdown to land alongside.
hive-c0re is a host unit that wired no CA trust at all. Harmless today
-- every endpoint it is handed defaults to plain http or loopback, so
nothing verifies a certificate -- and latent for the split-host shape the
options invite: `swarm.matrix.apiUrl`'s own example is an https URL, and
pointing it or `statusPublish.natsUrl` at another hive's gateway means
verifying a leaf signed by a CA generated at runtime.
Unlike the other consumers of this helper, hive-c0re already makes a
public-TLS call -- the OTEL exporter reaches whatever `otel.endpoint`
names. `SSL_CERT_FILE` replaces the trust store rather than adding to it,
so the bundle being system CAs + hive CA is what keeps that path working;
narrowing it to the hive CA alone would fix a case nobody hits yet and
break one that runs today.
Closes#3448.
New ui/ primitives: FormField (shared label+control wrapper),
TextField, SelectField, Button — each with a min-height touch target
(2.75em ~= 44px, WCAG 2.5.5) per mara's #3447 ask, and a max-width
instead of a fixed width so the control caps on desktop without
overflowing a narrow/touch viewport.
CreateAgentPage's name field + submit button now come from the kit
instead of page-scoped CSS; ComponentsPage gets a section for each new
primitive with an editable sample.
New GET /api/swarm on swarm-controller, backed by
services.hyperhive.swarm.name (SWARM_CONTROLLER_NAME env var, same
optionalAttrs-gated-on-option-resolving shape queueEnv/forgeEnv/etc.
already use). swarm-ui's <Shell> fetches it once and sets both
document.title and the header's brand text; falls back to the
existing static "hyperhive swarm" label when the operator never set
a name or the fetch fails.
Extracted the swarm-queue connect block out of main() into its own
connect_status_reader() fn to keep main() under clippy's line-count
lint after adding the new field wiring — no behavior change, same
comments moved as-is.
The controller's forge client speaks TLS to `https://<forge domain>`,
which the gateway serves with a leaf signed by the hive CA. That CA is
generated at runtime, so nothing build-time can name it and it is not in
the system store -- and `reqwest`/`rustls` resolves roots through
`rustls-native-certs`, whose `SSL_CERT_FILE` *replaces* the store rather
than adding to it. With no bundle wired, every forge call failed
`invalid peer certificate: UnknownIssuer` and agent creation died at its
first step.
`lib/hive-ca-trust.nix` already solved this, but only for containers: it
sources the CA through `/run/hive-ca/trust-bundle.pem`, a bind mount that
does not exist on the host. `hostUnit` makes it read the host copy and
wait on `hive-tls-ca.service` itself -- one flag driving both, because a
host source without that ordering is a race.
`enable` is the other half, and it is the sharp edge: a container caller
imports this into the container's module set, so it disappears with the
container. A host caller imports it at the host's top level, where
`imports` is unconditional -- without the flag, a hive with the
controller turned off would get a bundle oneshot and a `swarm-controller`
service conjured by `genAttrs`, holding an `SSL_CERT_FILE` and no
`ExecStart`.
Forgejo returns a bare `null` body (not `[]`) for a reactions list when
nothing has reacted yet. issue_reactions/comment_reactions deserialized
straight into forgejo-api's typed Vec<Reaction>, which has no null
tolerance, so issue show / pr show / view failed on every item with zero
reactions - i.e. nearly everything.
Generalize pr_status's existing null_as_empty (same quirk, hit earlier
on combined-status statuses) into a shared helper in verbs/mod.rs, and
fetch reactions via the raw JSON path (Client::get_api_json) with a
NullableVec<T> wrapper instead of the typed client's bare Vec<Reaction>.
Also names the failing request in errors going forward, since
get_api_json's error context includes the URL - closes the gap the
issue itself flagged (the old error said what didn't parse but not
what was fetched).
mara: "pls remove historical wording, only present pls".
The correction was written as a diff against what the docs used to claim
-- "this used to say X", "where this is going", "the intended state for
now". That is a changelog, and a reader arriving cold has to reconstruct
the current truth from it. The reasoning about why the old shape was
wrong belongs in the PR that changed it, not in the file.
Now says what is true: the controller interprets a delivery and emits a
semantic message; receipt is all that is wired today because the
swarm->hive channel does not exist yet.
mara asked for the docs and comments to be fixed in one PR before the
implementation, since a half-corrected description is what confuses a
reader coming back after a context compact.
Three more places said or implied "forward the payload":
- post_webhook_forge's doc ("relays the delivery to every hive ... the
payload is never parsed here")
- its OpenAPI request_body description ("the delivery can be relayed
unmodified") -- this one is published in the spec, so it was the most
externally visible of them
- DeliveryKind::as_str, which called the hook kind the event's routing
key; the message is semantic, and which hook it arrived on is an input
to deriving it rather than the thing sent
docs/swarm/README.md gains the direction next to the interim state, so
"two hooks, one of which only logs" reads as a step rather than a design.
mara's ruling: the controller should not forward received events, it
should create specific messages from them ("knowledge repo changed",
"deploy agent foo to rev abc123") and send those to whoever needs them.
The module doc said the opposite as settled design.
The argument it made was wrong in a specific way worth keeping: it
counted this daemon as a *second* place deciding what a delivery means,
while assuming the hives stay interpreters. They do not -- the parsing
moves rather than duplicating, so the end state has one interpreter, not
two.
Doc-only. The behaviour is unchanged and still receipt-only; what changes
is that the next reader is not told the wrong direction.
argus's optional note. Nothing in the doc was inaccurate, but an operator
who sees the activation-time warning has no thread back to the page that
explains why pinning matters -- and this section is the explanation.
States the one property they need from it: it never fails the rebuild, so
acting on it is theirs to do before the ids are minted.
`serverName` is baked irrevocably into every user and room id, so a hive
that rebuilds onto a new default is a *different homeserver*, not a
renamed one: existing accounts and rooms are stranded, and reverting the
config does not undo it. Its neighbours (`gatewayHost`, the forge domain)
are routing, rediscovered through `.well-known` and fixed by editing them
back. Same diff shape, three orders of magnitude apart in blast radius --
which is an asymmetry a module should carry rather than an operator.
An activation script and not `warnings`, which is where this obviously
belongs and does not work: the condition needs the host filesystem, and
`nixos-rebuild switch --flake` evaluates purely, where
`builtins.pathExists "/var/lib/..."` answers false rather than throwing.
A `warnings` entry gated on it would evaluate, deploy, and print nothing
on every real deployment.
Rendered only when `serverName` is null, so a pinned hive has no script
rather than a script that stays quiet -- a guard that cries wolf at a
correctly-configured deployment makes the next real one read as noise.
Never fails the activation: it warns about a choice that cannot be
undone, and refusing the rebuild of a hive that already chose
deliberately is the opposite of helping.
The probed path is read out of the container's own evaluated config
rather than hardcoded. A guessed path resolves cleanly and silently never
matches, which is the same failure this guard exists to catch one level
up.
mara asked whether SWARM_CONTROLLER_PUBLIC_URL should also be set when the
controller does not run on the same host, and then said the two read as one
service to her. Both are worth answering in the file rather than only in the
thread: that split is not representable today, and the reason is two hops
away from this line.
The UI's /api/ location proxies http://unix:<socketPath> -- a path that
resolves nowhere else -- and this daemon binds no TCP address at all. So the
vhost and the daemon are co-located by construction, and ui.enable is the
flag that declares the vhost rather than a guess about some host serving it.
Also names the tripwire: the day the daemon grows a TCP listener, this line
silently stops being right (env unset, registration quietly skipped, no
error). That is when an explicit publicUrl option becomes correct -- not
before, while there is exactly one derivable answer.
The endpoint landed inert: nothing pointed at it, so the only way to see
it work was to mint an HMAC by hand. Register the two swarm-wide hooks
at startup so a real forge event produces a journal line.
Registered ALONGSIDE the per-hive hooks, not instead of them. Every hive
keeps receiving and acting on its own deliveries; the controller gets a
copy and logs it. Moving the registration is a later step and has to be:
fan-out swarm->hive does not exist yet, so a hook moved now would point
at a receiver that forwards nowhere, silently on both sides.
Deliberately no stale-hook deletion arm, unlike the two per-hive
registrars this otherwise mirrors: theirs delete hooks matching their own
path with a foreign base, and the hives' hooks are not stale.
The route prefix is what keeps this safe. Both hive-side registrars
delete any hook ending in /webhook/knowledge or /webhook/config-pr with a
different base, so a swarm hook under those paths would be deleted by
every hive on every boot. Serving them under /webhook/forge/ avoids it,
and a test pins it -- there is nothing else that can.
SWARM_CONTROLLER_PUBLIC_URL is set only where the swarm vhost is served,
because a hook whose target_url nothing answers is worse than no hook.
A forge webhook is a machine POST carrying an HMAC and no session cookie,
so it cannot pass the authelia auth-request subrequest every other location
on this vhost uses. This location deliberately omits it; the HMAC check in
the controller is what guards the path.
Scoped to /webhook/forge/ rather than /webhook/ so a future endpoint under
the same prefix does not inherit the bypass.
The crate's module doc claimed no persistence and no writes, which this
change makes false, and docs/persistence.md had no swarm-controller entry
at all — the webhook secret is the daemon's first persisted state.
A Forgejo webhook has one target_url, so every hive registering the same
swarm-wide hooks is last-writer-wins rather than idempotent. The controller
is the only swarm-wide thing in the deployment, so it becomes the receiver.
It verifies the HMAC and treats the payload as opaque bytes keyed by the hook
kind in the URL path; it deliberately does not parse the payload, because the
hives' existing handlers already decide what a delivery means.
Nothing is registered against the endpoint yet. The replacement path is built
and observable before anything takes the old one away, so the swarm's single
target_url never points at a receiver that forwards nowhere.
Its last call sites were the JSON store's to_string_pretty/from_str,
which this branch replaced with serde_norway. Nothing in swarmctl/src
names it any more.
Caught by argus, and it is the second time: the same reviewer found a
dead reqwest in swarm-controller after a move earlier this week. No lint
in this workspace sees an unused dependency, so both were green locally
and in CI -- a move has two sides and my gate only looked at one. My
local gate now carries an advisory check for it.
Six places asserted the old design as fact, and none of them mention the
change by name -- the class of doc breakage that is found by asking what
a diff made untrue, not by grepping for a feature:
- swarmctl/README.md and swarm-authelia-bridge/README.md both described
their own private canonical store. The bridge's "known limitation"
section described the seam as unsolved; it is what this fixes, so it
becomes what both writers must uphold instead.
- docs/swarm/{sso,ui,secrets}.md described a rendered artifact.
- The repo CLAUDE.md entry for swarmctl said the same.
- docs/tools/swarmctl-cli.md is regenerated (CI diffs it against the
clap tree), picking up the removed --store flag.
Operator-facing where it is read: the hand-editing consequence (values
survive a rewrite, comments do not) is stated in sso.md, where an
operator is being told to edit the file, rather than only in a module doc.
#3414 fixed the missing-email defect in swarmctl only, so every identity
created by the bridge landed in users.yml with no `email`. A relying
party that asks for the claim does not degrade, it fails -- grafana's
OIDC login is the measured case (#3393).
Two writers of one file disagreeing about a field one of them treats as
required is not a difference worth keeping, and with the file shared the
result also depended on which tool wrote last.
Mutation-checked: removing the fill turns the new test red and nothing
else.
The second half of making one file canonical. swarmctl kept its own
private JSON store and rendered users.yml from it, against the same
physical file the bridge wrote -- the seam that made `swarm agent create`
refuse to run.
- users.yml is read before it is written, so users another writer added
are loaded rather than treated as a file to refuse or clobber. The
overwrite guard and the seed check go with the second store: they
existed to police two stores that could disagree.
- serde_norway replaces the hand-rolled emitter. Unknown top-level and
per-user keys round-trip through `extra`, so two writers cannot delete
each other's fields.
- The synthetic email moves from render time to the write path and is
stored. With the file as the store, "rendered but not persisted" has
nowhere left to live, and mara ruled the stored address correct.
- `--store` is removed rather than deprecated: a flag whose only
remaining effect is nothing reads as accepted and does nothing.
Three tests asserted the emitter's exact bytes, and one asserted the
guard. Rewritten rather than deleted -- as round-trips for the former,
and inverted for the latter, since "a populated file is READ" is the
behaviour this change is for and deleting its test would leave it
unpinned.
The bridge no longer has a private canonical store, so the env var
naming one is gone rather than repointed. What is left is the single
users file it now reads and writes directly.
That line is what made `swarm agent create` fail on this hive: the
bridge found no store at its own JSON path, saw users already in
`users.yml`, and refused to overwrite a file it had not written --
correctly, given two things claimed to be canonical for one file.
`swarm agent create` failed with "no user store at
swarm-authelia-bridge-users.json but users.yml already holds users" on
any hive that had users. The guard was correct; what was wrong is that
two files both claimed to be canonical for one physical file.
The bridge kept a private users.json and rendered users.yml from it,
while swarmctl kept its own pair against the same users.yml. A writer
whose own JSON was absent could not tell "nothing here yet" from
"someone else's users", so it refused to write at all.
users.yml becomes the store: read before write, through serde_norway
rather than a hand-rolled emitter. Unknown top-level and per-user keys
round-trip through `extra`, or whichever process writes second would
silently delete what the first added. Validation stays on the write path
-- a bare to_string(&store) serialises perfectly and drops the "no
control character ever reaches this file" guarantee silently.
The seed constant goes with the guard: nothing writes a seed now, an
absent file is an empty store, and left as a pub constant it read as if
the seed dance were still load-bearing.
swarm-authelia-bridge answered one 401 for four causes, including its own
client credentials being rejected by authelia — telling a caller its token
was invalid when the fault was ours. That cost a real diagnostic round-trip,
because the only thing separating the two was a log line inside a container.
Introspection now returns a three-way Verdict, and one classify() maps it to
either admission, a 401 (the caller's credential) or a 503 (ours). Admission
is unchanged: Ok is reachable from exactly one variant.
Closes#3423.
Modern browsers validate an <input pattern> attribute's regex in
Unicode-set ('v') mode, which is stricter about hyphen placement than
classic mode: `[a-z0-9-]` throws "Invalid character class" under 'v'
mode even though a trailing hyphen is unambiguous (and valid) in classic
regex. Reproduced directly: `new RegExp('[a-z0-9-]', 'v')` throws,
`new RegExp('[a-z0-9\\-]', 'v')` doesn't. Escaping the hyphen fixes it
without changing what the pattern matches.
atlas, on the issue: swarm-controller's problem+json conversion merged
(58588a68, closing hyperhive#3412 which this comment referenced as an
in-flight fix). Every first-party API is RFC 9457 now, so the non-JSON
fallback path is not backend-transition compatibility anymore — it's for
bodies this repo didn't author (a caller pointed elsewhere, or an
nginx-synthesised 502/504 that never reaches our handlers). Comment
updated to say that instead of pointing at a transition that's already
finished.
mara, on review: "i want each component to import its own css file
itself[;] if you need a bunch of extra css externally, its not a proper
component ... you may need to migrate other components that you would
want to use first".
WarnBanner is the Preact-component successor to the shadow-DOM
<hive-warn> custom element (same three-tier info/warning/error visual
language, colours copied faithfully from hive-warn.css). ApiErrorPanel
now composes it instead of owning a copy of the border/colour/pulse
rules itself — its own CSS is back down to just the layout that's
actually specific to it (heading, copy button, detail text).
Re-verified with a fresh mock-server screenshot: same rendered output as
before, now via composition instead of a duplicated banner shape.
Closes#3410.
`ApiErrorPanel` renders a ProblemDetails (RFC 9457) error nicely, with a
copy button so the full text can be pasted straight into a bug report.
No truncation of `detail` — on the #3363 incident that motivated this
issue, that string was the entire diagnosis.
`readApiError`/`problemMessage`/`ProblemDetails` are promoted out of
credentials.js's original `readErrorBody` into `@hive/shared/api-error.js`
(comment rewritten: the RFC 9457 rework has already landed everywhere
except swarm-controller's status route, #3412 in flight, so the raw-text
fallback is a compat shim for that one gap, not a general transition).
credentials.js's 4 call sites switch to the shared reader (kept as
one-line messages there, its result slots are single-line aria-live
regions, not a panel context).
Wired ApiErrorPanel into OverviewPage.tsx (the issue's own worked
example) and CreateAgentPage.tsx (second real call site).
Not built on <hive-warn> despite matching its visual language — that
custom element's CSS-as-text import only works under a build with
loader: 'text' for .css (dashboard's), and silently renders unstyled
under swarm-ui's default css loader (filed separately as #3415).
ApiErrorPanel is a self-contained light-DOM component instead, per
mara's own suggestion to keep it independent of the old UI's shapes.
Plugin management is server-admin scoped, and on an SSO hive nobody holds that
role: auto_assign_org_role grants an org role, and the built-in local admin
that does hold server admin cannot log in because the login form is disabled
whenever SSO is configured. Two individually-correct decisions leaving no path
to the plugin UI at all.
Declarative is the way through rather than a workaround for it -- plugins land
in the store and in git, survive a rebuild and a state reset, and the container
needs no runtime egress to grafana.com.
mkIf rather than passing the list through: upstream's default is null, while an
empty list is a real value pointing the plugin path at an empty store dir, so a
hive that sets nothing must keep seeing null.
Authelia serves no `email` claim for a user without one, and a relying party
that wants that claim does not degrade -- grafana falls through to
`<api_url>/emails`, a GitHub-ism authelia does not implement, and the login
dies with InternalError naming nothing useful. So an absent address is a
broken login rather than a sparse profile.
Uses `<username>@hyperhive.local`, the domain hive-c0re already gives every
agent's forge account and every hyperhive-authored commit. Not deployment-
derived: that would have to be plumbed in from config, and an operator already
supplying a domain may as well supply the whole address.
Synthesised in the renderer, never in the store: users.json stays honest that
none was supplied, an operator who later sets a real one is not fighting an
invented value, and existing users are fixed by the next render with no
migration step. A supplied address always wins.
The old test asserting an absent email is omitted pinned exactly the behaviour
that broke the login; it is split so the group half keeps its meaning and the
email half states the new contract.
Its three error paths returned a bare string with a status code, which forces
a caller to treat the whole body as prose. hive-c0re converted some time ago,
so swarm-controller was the last backend on the old shape -- and it is the one
behind the hive status page's 503, where the body is frequently the entire
diagnosis rather than a summary.
Adds the commitment to docs/conventions.md, since it was implied by the code
in one daemon and written down nowhere: an endpoint of ours answering with a
bare string is a bug to file, not something callers work around.
The test asserts the rendered response -- media type plus an addressable
detail -- rather than the problem_details value, because a handler that built
the value correctly and then returned it as a string would satisfy a test
written against the type alone.