Output & messaging contract
Status: Normative as of 2026-07-02. This is the single contract for every
user-facing string SHC produces — CLI output, streamed progress, errors,
warnings, hints, prompts, daemon logs, notifications, help text, and HUD
labels. It supersedes the output sections of docs/reference/cli/overview.md
(rewritten against this doc) and wins over any older description of output
behavior. Enforcement is listed in §15; drift against this doc is a defect,
not a preference.
Why it exists: an audit of the live CLI (2026-07-02) found five competing
output grammars across four adjacent commands — raw JSON dumps, compose-style
spinner blocks, key=value lines carrying prose, headerless hand-padded
pseudo-tables, and duplicated error lines in two formats. Every grammar was
locally reasonable; nothing owned the whole. This doc owns the whole.
0. Principles
Section titled “0. Principles”- One grammar per situation. A situation (a list, a mutation result, a warning, a failure) has exactly one output shape, produced by exactly one shared helper. Commands never hand-roll a shape a helper exists for.
- stdout is data, stderr is chrome. Already normative in
core/render/streams.go; restated here because every rule below hangs off it. - Say it once. No fact, hint, or error appears twice in one invocation.
- Human words for humans, structure for machines. tty/text modes speak
plain sentences;
-o json/-o yamlcarry the structure.key=valueis not a human grammar and is retired from tty/text output. - No internal vocabulary in user-facing strings. No field names
(
stack_name), no mechanism narration (“backfilled by reconcile”), no plan/phase codenames. Users see product nouns (§11) only.
1. Streams
Section titled “1. Streams”render.Streams(mode, stdout, stderr) is the only way a command resolves its
writers. Data (tables, documents, result lines) → data writer. Chrome
(progress, warnings, hints, prompts, errors) → chrome writer. Under
structured modes both fold onto stdout as one parsable document; under
tty/text chrome goes to stderr. No direct os.Stdout/os.Stderr writes in
command code.
2. Render modes
Section titled “2. Render modes”- The global
-o, --outputflag owns format selection:tty | text | json | yaml. Default:ttywhen stdout is a terminal,textwhen piped. - Commands MUST NOT re-register
--output/-o, add--jsonbooleans, or spell format as--format. (66 local re-registrations existed at audit time; they also shadowed global shorthands — see §10.) - All four modes work on every command. A command never “supports only
json|text”: structured rendering is free via the SmartFormatter
(
core/render), which also provides--fields/--filter/--exclude. NO_COLOR(and non-tty stdout) disables ANSI everywhere, includingrender.FormatValueTTYand YAML highlighting — not just lipgloss surfaces.
3. Data output (lists and details)
Section titled “3. Data output (lists and details)”- Lists render through
render.Print→ column table: UPPER_SNAKE headers, two-space gutter, empty columns dropped, width-fitted. No hand-rolledtabwriter/%-32stables, no headerless lists, no lowercase headers. - Empty list: exactly
no <plural-noun>on the data writer (e.g.no backups), dim in tty. Not silence, not a bare header row, not exit 1. - Details (one object) render as the SmartFormatter’s flat-fields + nested-boxes shape.
- Markers: an “active/current” row is marked in a dedicated leading column
with
*. A marker that no data path can ever set is a defect (dead affordance), not a feature.
4. Result lines (mutation success)
Section titled “4. Result lines (mutation success)”One line on the data writer, grammar:
<verb-past> <noun> "<name>"[ (<short fact>[; <short fact>])]Examples: installed stack "plane" · created tenant "test" (switched) ·
removed node "node-3" · rotated cluster CA (generation 4).
- Lowercase, no trailing period, names in double quotes.
- tty prefixes a green
✔; text mode is the bare sentence (no glyph). - Secondary facts the user must act on move to a hint (§6), not into the
result line, and never into a
key=valuefield. -o json/yamlrender the full result object instead; the sentence is tty/text only.
5. Warnings
Section titled “5. Warnings”warning: <lowercase message>- Chrome writer. Yellow in tty (color only — same text every mode).
- Coded warnings (X2xxxxx) include the code:
warning [X200912…]: <message>. - One helper. The four shapes found at audit time (
warning=,warning:, stdout vs stderr, bare prose) are all this one now. - Warnings crossing HTTP (204 +
X-SHC-Warning-Code) are read by the client and re-emitted through the same helper — never silently dropped.
6. Hints (next steps)
Section titled “6. Hints (next steps)”hint: <sentence with the command in backticks>Example: hint: retrieve the admin password with \shc vault get keycloak_admin_password -t test“.
- Chrome writer, dim in tty. At most one hint per fact per invocation.
- Every “run this next” remediation — in results, warnings, and error
templates — uses backticks around the command. No unquoted
run shc x.
7. Errors
Section titled “7. Errors”- Exactly one error line per failure. The canonical render is
renderRunError:shc: [X…CODE] messageon the chrome writer (text/tty), or the coded envelope as part of the stdout document (json/yaml). - A streamed operation that already rendered
✘ <detail>inline returns its error wrapped to satisfySilentExit() bool, so the top-level line is suppressed. The double-print (inline row +shc:line) is a defect. - Exit codes: usage/parse errors → 2; every runtime failure → 1.
A
*coded.CodedErroris by definition runtime — it short-circuits the usage-error heuristics. (The olderrors.mdclaim “4xx→1, 5xx→2” is retired; the 4xx/5xx class travels in the JSON envelope for scripts.) - Message style (enforced by arch-fitness check 8, extended): lowercase, single flat clause, no colons/semicolons/parens, no trailing period. Include the scope that disambiguates the failure — a not-found in a scoped namespace names the tenant and environment searched.
- One code, one meaning: a code is never minted with a second message template (full-string collisions are defects; numeric-prefix collisions are ratcheted).
8. Streamed progress (install/update/recover/init/…)
Section titled “8. Streamed progress (install/update/recover/init/…)”The multiplexed operation renderer (operation_live) is the only spinner
grammar. The Python-parity livedisplay fallback either drives the same
grammar through an adapter or is retired; two renderers for one verb is a
defect.
- Announce line (chrome, before the stream):
<verb-ing> <noun> "<name>"—installing stack "plane",installing system apps (postgres, keycloak, openobserve, traefik) for tenant "master". Lowercase; same grammar for single and system installs. - Block header:
[<stack>] <State> <done>/<total>— always the stack name, never a literal[+]. State words:Pulling,Starting,Healthy,Failed,Degraded(vocabulary perstatus-states.md). A failure before anything deployed isFailed, neverDegraded. - Rows:
<mark> <service> <status>[ <duration>]with marks: spinner (in flight),✔(settled healthy),✘(failed),-(not run / frozen). - Convergence is mandatory: the final frame of a successful stream
shows every row
✔and the header count complete; the final frame of a failed stream shows the failing row✘, in-flight rows frozen to-(never a stale animation frame), and never promotes an unfinished row to✔ healthy. - Plain (non-tty) fallback: one shared glyphless grammar for all flows —
<stack>: <event>lines, final line = the result line (§4) or the error. No Unicode glyphs on pipes/CI.
9. Confirmation prompts
Section titled “9. Confirmation prompts”- Every destructive verb (
rm,uninstall,purge,rotate,leave, force-moves) prompts:<verb> <noun> "<name>"? [y/N]on the chrome writer. --yes/-yskips the prompt. A registered--yesthat is parsed and discarded is a defect (four existed at audit).- Decline prints
aborted(chrome) and exits 1 — never a silent exit 0. - Non-tty stdin without
--yesrefuses loudly with a coded error naming the flag — never silently proceeds, never silently exits 0. - Unknown subcommands under a group are errors (cobra
NoArgs/unknown-command error), not exit-0 help and never fall-through into the group’s default action.
10. Help text
Section titled “10. Help text”- Short: lowercase imperative verb phrase, no trailing period, ≤ 60 chars. Same casing on every command (9 capitalized outliers at audit).
- Long: required for any command with > 6 flags, destructive semantics, or async behavior. Sentences, wrapped at 80.
- Example: required for the flag-heavy verbs (update, backup create/restore, recover, clone, install, move at minimum).
- Flags: kebab-case long names. Never re-register a global flag name
locally (it silently destroys the inherited shorthand —
backup create -ebroke this way). Never document a shorthand that isn’t registered. One concept = one long name across the tree (--tail, not--tailhere and--linesthere). - Scope ladders in help text use ADR-0003 vocabulary verbatim:
global|tenant|environment|stack|deployment(system/basesurvive only as accepted read synonyms, never in help). - List verbs:
lsis canonical;listis a cobraAliasesentry, not a second registered command.psexists only where it means processes/runs, and never as a synonym oflsin the same family.
11. Vocabulary
Section titled “11. Vocabulary”- Nouns (user-facing): stack, app (catalog entry), tenant, environment, node, cluster, backup, volume, connection, secret, certificate. The HUD, CLI, API messages, and docs use the same noun for the same thing.
- Status words: exactly the sets in
docs/architecture/status-states.md(includingpausedandunknown). One Go source of truth for status buckets — duplicated bucket maps are defects. - Names are double-quoted in prose (results, announces, warnings, hints). Error templates converge opportunistically (no mass rewrite).
- Banned in user-facing strings and logs: internal field names, plan/ phase codenames, mechanism narration. Real X-codes and issue numbers are fine.
12. Daemon logs
Section titled “12. Daemon logs”core/log(corelog) is the only logging API. Nofmt.Fprintf(os.Stderr, …)in daemon/subsystem/api code (breaksSHC_LOG_FORMAT=json, skips redaction and the OTel bridge); no stdliblog.Printf(the two core/config import-cycle sites get an injected logger seam).- The CLI calls
corelog.Inittoo — redaction andSHC_LOG_FORMATapply to every process, not just the daemon. - Keys: the
Field*constants are the schema; the error value is alwayserror, node identity is alwaysnode_id. New concepts get a constant before first use. - Levels:
Error= failed and needs an operator;Warn= degraded, retrying, or unexpected-but-continuing;Info= lifecycle milestones;Debug= diagnosis detail. A reconcile/renewal/materialize FAILURE is never Debug. - Operator-actionable Warn/Error carries an X-code (typed corelog call) so it
reaches the event bus and notifier — untyped
.Warn/.Erroron injected loggers is a bypass, not a second tier. - Message style: lowercase, no trailing period, no ALL-CAPS.
13. Notifications
Section titled “13. Notifications”- Notification content uses the same vocabulary (§11) and status words, and carries the X-code of the event that triggered it.
- The delivery pipeline must be real: a rule engine whose dispatcher never
starts (the audit state) is treated as a broken contract, not a
todo — wiring the worker pool and the
shc notification testdaemon round-trip are part of this contract’s remediation. - Config-reload keys match both the bare
notificationskey and dottednotifications.*prefixes.
14. HUD
Section titled “14. HUD”- Status words, nouns, and buckets come from the same sources as §11 — the HUD never maintains a private status taxonomy.
- Every advertised affordance works where it is advertised: an action chip
shown on a row must have a handler for that view; the help overlay lists
only actions that work in the HUD and labels CLI-redirects as such; hint
text names the real keybinding (
ctrl+r, notr). - Toasts/flash lines only for errors; passive indicators for everything else (existing house rule, already honored).
15. Enforcement
Section titled “15. Enforcement”Added to scripts/arch-fitness.sh (ratchets unless noted):
- no-local-output-flag — re-registrations of
--output/-o,--jsonbools, format---formatflags outside the CLI root: ratchet → 0. - daemon-stderr-ban —
fmt.Fprint*(os.Stderrunderdaemon,subsystems(the ce daemon/subsystems trees carry the rule in the ce publish flow): ratchet → 0. - template-style — check 8 extended: semicolons banned in message templates alongside colons/parens/trailing periods.
- key-value-output-ban —
key=valueFprintfpatterns in command RunE paths: ratchet → 0 as migration lands. - shorthand-shadow —
flag_shorthand_test.goextended to fail when a local flag shadows an inherited global’s shorthand (hard test, not ratchet). - example-floor — cobra
Example:count must not decrease once the top-verb examples land.
Everything not enforceable mechanically is enforced by this doc + review.
Cross-references
Section titled “Cross-references”core/render/streams.go— streams discipline (normative code).core/render— SmartFormatter, tables, modes.core/log— field constants, typed emission, redaction.docs/architecture/status-states.md— status vocabulary.docs/decisions/ADR-0003-one-scope-type.md— scope-tier vocabulary.docs/reference/errors.md— code registry (regenerated; exit-code section updated by this contract).- Audit evidence:
docs/reviews/2026-07-02-output-voice-audit.md.