Skip to content
SHC Docs

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.


  1. 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.
  2. stdout is data, stderr is chrome. Already normative in core/render/streams.go; restated here because every rule below hangs off it.
  3. Say it once. No fact, hint, or error appears twice in one invocation.
  4. Human words for humans, structure for machines. tty/text modes speak plain sentences; -o json/-o yaml carry the structure. key=value is not a human grammar and is retired from tty/text output.
  5. 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.

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.

  • The global -o, --output flag owns format selection: tty | text | json | yaml. Default: tty when stdout is a terminal, text when piped.
  • Commands MUST NOT re-register --output/-o, add --json booleans, 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, including render.FormatValueTTY and YAML highlighting — not just lipgloss surfaces.
  • Lists render through render.Print → column table: UPPER_SNAKE headers, two-space gutter, empty columns dropped, width-fitted. No hand-rolled tabwriter/%-32s tables, 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.

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=value field.
  • -o json/yaml render the full result object instead; the sentence is tty/text only.
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.
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.
  • Exactly one error line per failure. The canonical render is renderRunError: shc: [X…CODE] message on 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 satisfy SilentExit() 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.CodedError is by definition runtime — it short-circuits the usage-error heuristics. (The old errors.md claim “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 per status-states.md). A failure before anything deployed is Failed, never Degraded.
  • 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.
  • Every destructive verb (rm, uninstall, purge, rotate, leave, force-moves) prompts: <verb> <noun> "<name>"? [y/N] on the chrome writer.
  • --yes/-y skips the prompt. A registered --yes that 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 --yes refuses 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.
  • 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 -e broke this way). Never document a shorthand that isn’t registered. One concept = one long name across the tree (--tail, not --tail here and --lines there).
  • Scope ladders in help text use ADR-0003 vocabulary verbatim: global|tenant|environment|stack|deployment (system/base survive only as accepted read synonyms, never in help).
  • List verbs: ls is canonical; list is a cobra Aliases entry, not a second registered command. ps exists only where it means processes/runs, and never as a synonym of ls in the same family.
  • 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 (including paused and unknown). 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.
  • core/log (corelog) is the only logging API. No fmt.Fprintf(os.Stderr, …) in daemon/subsystem/api code (breaks SHC_LOG_FORMAT=json, skips redaction and the OTel bridge); no stdlib log.Printf (the two core/config import-cycle sites get an injected logger seam).
  • The CLI calls corelog.Init too — redaction and SHC_LOG_FORMAT apply to every process, not just the daemon.
  • Keys: the Field* constants are the schema; the error value is always error, node identity is always node_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/.Error on injected loggers is a bypass, not a second tier.
  • Message style: lowercase, no trailing period, no ALL-CAPS.
  • 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 test daemon round-trip are part of this contract’s remediation.
  • Config-reload keys match both the bare notifications key and dotted notifications.* prefixes.
  • 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, not r).
  • Toasts/flash lines only for errors; passive indicators for everything else (existing house rule, already honored).

Added to scripts/arch-fitness.sh (ratchets unless noted):

  1. no-local-output-flag — re-registrations of --output/-o, --json bools, format---format flags outside the CLI root: ratchet → 0.
  2. daemon-stderr-banfmt.Fprint*(os.Stderr under daemon, subsystems (the ce daemon/subsystems trees carry the rule in the ce publish flow): ratchet → 0.
  3. template-style — check 8 extended: semicolons banned in message templates alongside colons/parens/trailing periods.
  4. key-value-output-bankey=value Fprintf patterns in command RunE paths: ratchet → 0 as migration lands.
  5. shorthand-shadowflag_shorthand_test.go extended to fail when a local flag shadows an inherited global’s shorthand (hard test, not ratchet).
  6. example-floor — cobra Example: count must not decrease once the top-verb examples land.

Everything not enforceable mechanically is enforced by this doc + review.

  • 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.