docs(#2980): ratify the payload-carried error idiom as a degraded result (#3270)

* docs(#2980): ratify the payload-carried error idiom as a degraded result

Records ADR-2980: an `error` key in a gsd-tools result payload on stdout with
exit 0 is a ratified contract meaning the command ran to completion and is
reporting a condition, not a process failure. Faults keep stderr + exit 1 +
--json-errors.

Normalizing the 42 output({error}) sites to exit 1 was declined on measured
blast radius (get_impact rates cmdStateSnapshot CRITICAL; output has 170 direct
callers) — a Hyrum's Law break with no versioning escape hatch for an exit code.

Adds the "Degraded results vs faults" section to docs/json-errors.md with a
correct-caller recipe, indexes that page from docs/README.md, and records the
two-channel contract in the CONTEXT.md I/O Module entry. No code change.

Closes #2980

* docs(#2980): correct the site count and cross-refs after review

The isolated review found the population figure was the answer to a regex,
not to the question. `output\(\{\s*error:` only matches literals whose FIRST
key is `error`; re-deriving it by brace-matching output()'s first argument
gives 60 sites across 9 modules (42 error-first + 18 error-not-first), adding
workstream.cts, phase.cts and gsd2-import.cts. roadmap.cts:260 is the case in
point — it carries `error` alongside `found:false` and is the site that
actually produces the documented `roadmap get-phase` output.

Also from review: correct the --raw claim (11 sites pass a rawValue, not 2),
reconcile the missing-required-argument count to the 7 verified sites, link
the bare ADR-2966 references per the ADR lifecycle rule, and fix 'licence'
to American spelling.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-09 16:44:29 -04:00
committed by GitHub
parent c07297cd50
commit 2ac21c7fdb
5 changed files with 295 additions and 1 deletions

View File

@@ -169,7 +169,7 @@ Module owning validation for Installer Migration Module records and planned acti
Primary installer for all runtimes. Single production file: `bin/install.js` (hand-authored JS — it is NOT generated from `src/*.cts`; ADR-1508 keeps it hand-authored deliberately, and no `npm run build` step emits it). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (18 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, hermes, kimi, kimi-code, kilo, opencode, pi, qwen, trae, windsurf, zcode). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). The same module exposes `detectAntigravityDirAmbiguity(opts)` — a side-effect-free probe reporting whether multiple `~/.gemini/antigravity{,-ide,-cli}` dirs coexist and which one GSD's `gsd-core/VERSION` marker (the `dot-home-nested` `probeExists`) resolves to, for installer / `/gsd-update` operator guidance when a pre-#217 install landed in the wrong sibling dir (#1441). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Five runtimes with non-recursive skill loaders (cline, qwen, hermes, augment, trae) use a nested router layout: 6 `gsd-ns-*` router bundles emitted as top-level skills, with concrete skills nested at `<router>/skills/<name>/SKILL.md` (hermes prefix='': `skills/gsd/ns-*/…`). claude (reverted from nested per #924 — the Skill tool errors on unrouted names) and antigravity (one-level scan, but concrete skills must be top-level discoverable) plus the remaining skills-runtimes (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) use the flat `skills/gsd-<stem>/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module.
### I/O Module
Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`).
Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). **Degraded result vs fault (ADR-2980, #2980):** the two emitters are a deliberate two-channel failure contract, not a drift. A **fault** is `error(message, reason)` — stderr, exit **1**, structured `{ok:false,reason,message}` envelope under `--json-errors`. A **degraded result** is `output({ error: … })` — stdout, exit **0**, `--json-errors` does not apply — and means the command ran to completion and is reporting a condition (absent artifact, and in practice also missing-argument and unusable-input cases) through its result; a caller detects it by inspecting the payload, never by exit code. Ratified across **60 sites in 9 modules** (`state` 25, `verify` 8, `workstream` 7, `frontmatter` 6, `commands` 5, `template` 3, `gsd2-import` 2, `phase` 2, `roadmap` 2) because normalizing them to exit 1 is a Hyrum's Law break over a CRITICAL radius (`get_impact(cmdStateSnapshot)`; `output` has 170 direct callers). #2966/#2980 record "42 sites" — that counts only literals whose FIRST key is `error` (the `output\(\{\s*error:` regex); 18 more put another key first (`{found:false, error}`) and are identical in contract, so 60 is the population and 42 is a subset. New code prefers the fault path or a named-field result (`{updated:false, reason}`), not a 61st site. Known cost carried by the decision: the exit code does not distinguish absent from unusable, which is ADR-1411's "corrupt is not absent" open edge. Docs: `docs/json-errors.md` → "Degraded results vs faults". Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`).
### Markdown Sectionizer
Canonical markdown-structure parsing seam (`gsd-core/bin/lib/markdown-sectionizer.cjs`, generated from `src/markdown-sectionizer.cts`). Pure functions, Node built-ins only. Exports: `stripFencedCode(content) → { text, unterminatedFence }` (CommonMark-correct state machine, CRLF-safe, signals unterminated fences); `stripInlineCode(content) → string` (per-line CommonMark inline-code-span stripper — removes `` `code` `` spans while leaving fenced blocks to `stripFencedCode`; #2365); `scanInlineCodeSpans(content) → InlineCodeSpan[]` (locates every inline code span as `{ start, end, content }`, offsets into the full string and spans never crossing a `\n`; callers that need the span CONTENT (e.g. api-coverage's package-name evidence, #2365) use this, callers that just want spans gone use `stripInlineCode`); `tokenizeHeadings(content) → HeadingToken[]` (ATX headings outside fenced blocks, `{ level, text, line, offset }`); `collectSections(content, stopPredicate) → Section[]` (line-by-line section collection driven by a heading predicate); `collectSection(content, headingPredicate, { levelBounded, stripFences }) → Section | null` (single named section with level-bounded stop); `iterateBullets(sectionText) → BulletItem[]` (dash/checkbox/numbered markers with indented continuation); `extractTaggedBlocks(content, tagName) → string[]` (inner text of every `<tagName>…</tagName>` block in document order, tagName regex-escaped, caller decides fence-stripping — generalises `decisions.cts`'s bespoke extractor for T1); `replaceSection(content, section, newBody) → string` (pure character-offset splice using `Section.bodyStart`/`bodyEnd` for read-modify-write callers — eliminates T6 `state.cts`'s 7× inline `content.replace` pattern); `withSection(content, target, edit) → string` (resolve the section whose heading matches `target` — exact heading text or a `HeadingToken` predicate — and run `edit(body)` against ONLY that section's body before splicing the result back; bounded no-op when no heading matches or `edit` returns the same/non-string body; ADR-2143 §4 structurally retires the #2130/#2067/#2080 boundary-crossing class by confining any regex the caller runs to the matched section). `Section` carries `bodyStart`/`bodyEnd` offsets for `replaceSection`. ADR-1372 (epic #1372) establishes this seam and a tiered migration plan (T0–T7) to retire the 8+ ad-hoc markdown parsers and ~20 inline section-collects across `src/*.cts`. New `src/*.cts` modules must import this seam instead of hand-rolling fence strippers or heading-regex section walks (enforced by the `no-adhoc-markdown-parsing` ESLint rule landing in tier T7).

View File

@@ -56,6 +56,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- [Commands](COMMANDS.md) — every command with flags and examples
- [Configuration](CONFIGURATION.md) — full config schema, model profiles, git branching strategies
- [CLI tools](CLI-TOOLS.md) — `gsd-tools.cjs` programmatic API for workflows and agents
- [JSON error mode](json-errors.md) — `gsd-tools` failure channels: faults (stderr, exit 1) vs degraded results (stdout, exit 0), and the reason-code taxonomy
- [Features](FEATURES.md) — complete feature index
- [Inventory](INVENTORY.md) — installed skills and surface map
- [STATE.md schema](reference/state-md.md) — field-by-field reference for `.planning/STATE.md`

View File

@@ -0,0 +1,184 @@
# ADR-2980: A payload-carried `error` key is a degraded result, not a fault
- **Status:** Accepted
- **Date:** 2026-08-09
- **Issue:** [#2980](https://github.com/open-gsd/gsd-core/issues/2980)
- **Supersedes:** —
- **Relationship to prior work:** Resolves the decision [ADR-2966](2966-loop-qa-walk.md) explicitly deferred when its `soft-error-exit-zero` smell first fired. Constrained by [ADR-1411](1411-resolution-provenance.md) (resolution must report its provenance) and [ADR-227](227-input-validation-shape-not-just-type.md).
## Context
`gsd-tools` reports failure through two different idioms, and they disagree about the exit code.
| Idiom | Stream | Exit | Honors `--json-errors` |
|---|---|---|---|
| `error(message, reason)` | stderr | **1** | yes → `{ok:false,reason,message}` |
| `output({ error: … })` | stdout | **0** | no — it is a payload, not an error envelope |
The second idiom is used at **60 call sites across nine modules**:
| Module | Sites | | Module | Sites |
|---|--:|---|---|--:|
| `src/state.cts` | 25 | | `src/template.cts` | 3 |
| `src/verify.cts` | 8 | | `src/gsd2-import.cts` | 2 |
| `src/workstream.cts` | 7 | | `src/phase.cts` | 2 |
| `src/frontmatter.cts` | 6 | | `src/roadmap.cts` | 2 |
| `src/commands.cts` | 5 | | **Total** | **60** |
> **On the number 42.** [#2966](https://github.com/open-gsd/gsd-core/issues/2966) and
> [#2980](https://github.com/open-gsd/gsd-core/issues/2980) both record this population as *42 sites
> across six modules*. That figure counts only the sites where `error` is the object literal's
> **first** key — the shape a line regex such as `output\(\{\s*error:` matches. Eighteen further
> sites put another key first (`src/roadmap.cts:260` is
> `output({ found: false, error: 'ROADMAP.md not found' }, raw, '')`) and are identical in contract.
> 42 is a real subset, not the size of the contract; the count was re-derived here by brace-matching
> the first argument rather than by line regex. **Do not "correct" 60 back to 42.**
Observable today:
```console
$ gsd-tools state-snapshot # in a project with no STATE.md
{
"error": "STATE.md not found"
}
$ echo $?
0
```
`docs/json-errors.md` described only the first idiom. A reader consulting it would conclude that
every `gsd-tools` error goes to stderr with exit 1, and would write the obvious shell caller:
```sh
if ! gsd-tools state-snapshot > snap.json; then
echo "failed" # never reached — the process exited 0
fi
```
The failure is visible only to a caller that parses the payload and knows to look for an `error`
key. Workflows invoke `gsd_run <cmd>` and branch on exit status, so the gap is not hypothetical.
This surfaced from the loop QA walk ([ADR-2966](2966-loop-qa-walk.md)) as a `soft-error-exit-zero`
smell — *legal under today's implementation but structurally questionable*. That ADR measured the
blast radius, declined to act inside a QA-harness ADR, and said the question "warrants a separate,
deliberate decision". This is that decision.
### Why it is not simply a bug
Many sites show deliberate intent — an `error` key returned **alongside a valid result**:
```js
// src/roadmap.cts:310 — cmdRoadmapAnalyze
output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw, undefined);
// src/roadmap.cts:260 — cmdRoadmapGetPhase
output({ found: false, error: 'ROADMAP.md not found' }, raw, '');
```
Under that reading exit 0 is correct: the command succeeded in determining that the artifact is
absent. That matches the project's existing guidance for bounded subprocesses —
`CONTEXT.md`'s `DEFECT.UNBOUNDED-SUBPROCESS.fix-forward` prescribes "on timeout return degraded
result + structured warning rather than throw" — and it is the same instinct
[ADR-1411](1411-resolution-provenance.md) encodes: a resolution miss is reported, not thrown.
## Decision
**The payload-carried `error` key is a ratified contract, not an accident. It stays.** All 60 call
sites are unchanged; no code moves.
Precisely, the contract now documented in [`docs/json-errors.md`](../json-errors.md):
> A JSON result on **stdout** carrying an `error` key, with **exit 0**, means the command **ran to
> completion and is reporting a condition through its result**. It is not a process failure. A
> caller that needs to detect it must inspect the payload; the exit code will not tell it, and
> `--json-errors` does not apply.
A **fault** keeps the other path: `error(message, reason)` → stderr, exit 1, structured envelope
under `--json-errors`. Usage errors keep the third: `ExitError` → plain text on stderr, its own
exit code.
**New code should prefer the fault path, or a named-field result.** This ADR ratifies an existing
population; it is not a license to add a 61st site. Where a verb genuinely needs to report a
non-fatal condition in its payload, the richer shape `state update-progress` already uses —
`{"updated": false, "reason": "Progress field not found in STATE.md"}`, with no overloaded `error`
key — is the better model.
### Options declined
**Option 2 — split the vocabulary** (`status: "absent"` in place of `error`). Declined. It buys a
cleaner vocabulary at the same compatibility cost as Option 3: any caller already reading `.error`
stops seeing it, and it still requires sweeping every site.
**Option 3 — normalize every site to exit 1.** Declined on measured blast radius.
`get_impact` rates `cmdStateSnapshot` — the function this idiom threads through — **CRITICAL**:
[ADR-2966](2966-loop-qa-walk.md) recorded 55 affected symbols across 23 processes, and a re-measure on 2026-08-09 at depth 5
reports ≥200 affected symbols across 41 files and 21 processes. `output` itself has **170 direct
callers**. Flipping 0 → 1 across that seam would break every caller currently treating exit 0 as a
soft signal.
That is a textbook **Hyrum's Law** break: the exit-0 behavior is observable, has been in production
across 60 sites, and is therefore depended upon whether or not anything promised it. A CLI's exit
code has no versioning escape hatch — there is no `/v2/` for `$?`. Hyrum's own prescription for a
long-lived observable behavior is to *document what is stable*, which is what this ADR does.
## Consequences
**Good.**
- The idiom is a chosen contract with a written rule, so a caller can be correct on purpose rather
than by accident. The gap that made the obvious shell caller wrong is closed at the documentation
layer, which is where it existed.
- Zero risk. No call site, exit code, or payload shape changes.
- [ADR-2966](2966-loop-qa-walk.md)'s ratchet rule — *a smell must terminate in either an assigned defect or a corrected
detector* — is satisfied through the assigned-defect branch, resolved as "keep".
**Costs, stated plainly.**
1. **The 60 sites are not a uniform population, and the contract is broader than the motivating
example.** The issue framed the idiom as "an `error` key alongside a valid empty result". That
shape is real and common — the 18 sites that put another key first are largely it
(`{found:false, error}`) — but it does not describe the whole population. The rest divide into:
- *absent artifact* — the largest group; a bare `{error}` or `{error, <echo of the input>}`, e.g.
`{"error":"STATE.md not found"}`;
- *missing required argument* — **at least seven** sites, all verified within the error-first
subset: `src/state.cts` 599, 834, 902, 969, 1080 and 2814 (`'text required'`,
`'summary required'`, `'phase, plan, and duration required'`,
`'milestone required (--milestone <vX.Y>)'`) plus `src/template.cts:269`
(`'File already exists'`). These are faults wearing the degraded shape. Under this ADR they are
correctly *shaped* but arguably wrongly *classified*. "At least" is deliberate — the 18
error-not-first sites were not individually classified;
- *unusable input* — `cmdStateAdvancePlan` (`src/state.cts:553-585`) reports an unparseable
STATE.md through the same channel as a missing one.
Reclassifying any of them is a code change and is out of scope here.
2. **Absent and unusable are not distinguishable by exit code.** [ADR-1411](1411-resolution-provenance.md)'s
2026-07-26 amendment ("corrupt is not absent") requires those classes to stay distinguishable.
Today only the message text separates them — and the message is explicitly documented as
unstable, so a caller cannot depend on it. This ADR does not close that gap; it names it.
3. **`--raw` is not uniform on the error path.** `output(result, raw, rawValue)` prints `rawValue`
only when it is not `undefined`. Most sites pass `undefined` (eight in `src/verify.cts` omit the
argument entirely), so `--raw` still hands the caller the JSON error object. **Eleven sites pass
something else** and therefore behave differently under `--raw`: `src/commands.cts` 1481, 1546,
1553, 1569, `src/phase.cts` 246, 692, `src/roadmap.cts:260`, `src/state.cts:436` and `:2566` pass
`''` or `'false'`; `src/state.cts:2436` passes the message text; `src/template.cts:100` passes a
template path. A caller cannot assume either behavior from `--raw` alone.
4. **The `soft-error-exit-zero` smell keeps firing** on `state-snapshot` and `roadmap get-phase`,
now against behavior this ADR ratifies. Per [ADR-2966](2966-loop-qa-walk.md) §5 a smell never fails a build and never
folds into `.failed`, so this costs nothing but noise — but a detector reporting a ratified
contract is reporting a decision, not a finding. Re-pointing it is a code change and is out of
scope here.
## Revisit if
- A caller needs to distinguish **absent** from **unusable** programmatically. That is
[ADR-1411](1411-resolution-provenance.md)'s open edge, and it is the most likely reason this
decision gets reopened. The in-band mechanism that ADR already prescribes — naming the cause in a
provenance field — would fit these payloads without touching a single exit code, which makes it
strictly cheaper than Options 2 and 3.
- The missing-required-argument sites (cost 1 above) produce a real caller bug. Those seven are the
subset with the weakest claim to exit 0, and they could be moved to `error()` on their own, with a
far smaller radius than the full 60.
- A future `gsd-tools` major version provides a compatibility boundary that a CLI exit code
otherwise lacks.

View File

@@ -183,6 +183,7 @@ These govern the system as it stands. Cite these.
| [ADR-2782](2782-reviewer-lane-capability-surface.md) | Reviewer Lane — the cross-AI reviewer handoff becomes a declared capability surface | Accepted | — |
| [ADR-2866](2866-install-surface-resolution.md) | Install-surface resolution — the install pipeline resolves `(runtime × scope × trigger)` as a value | Accepted | — |
| [ADR-2966](2966-loop-qa-walk.md) | Test the five-step loop as a continuous walk, not isolated points | Accepted | — |
| [ADR-2980](2980-payload-carried-error-is-a-degraded-result.md) | A payload-carried `error` key is a degraded result, not a fault | Accepted | — |
| [ADR-3180](3180-planning-semantic-model-single-owner.md) | Planning Semantic Model — Single Owner per Derivation | Accepted | — |
| [ADR-3212](3212-lexical-seam-consolidation.md) | The Lexical Seam — Safe Pattern Construction, Line-Terminator Normalization, and Tokenizer-First Stateful Grammars | Accepted | — |
| [ADR-3660](3660-runtime-artifact-layout-module.md) | Runtime Artifact Layout Module owns per-runtime artifact placement | Accepted | [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) |

View File

@@ -9,6 +9,12 @@ grepping raw text (see `CONTRIBUTING.md` — "Prohibited: Raw Text Matching on
Test Outputs"). Usage errors are an intentional exception — see the
`ExitError` carve-out below.
> **This page describes one of two failure channels.** A second, equally
> intentional one reports conditions in the **result payload on stdout with
> exit 0**. A caller that branches on exit status alone will not see it. Read
> [Degraded results vs faults](#degraded-results-vs-faults--read-this-before-writing-a-caller)
> before writing anything that consumes `gsd-tools` output.
## Activating
Either flag or env var activates the mode:
@@ -52,6 +58,108 @@ assert on the exit code and (if needed) the plain-text message. The
"parse stderr as JSON" guidance below applies only to the structured-envelope
branch (non-`ExitError` failures).
## Degraded results vs faults — read this before writing a caller
`gsd-tools` has **two** ways of telling you something went wrong, and they use **different exit
codes**. The wire format above describes only one of them. If you write a caller that branches on
exit status alone, you will silently miss the other.
| | **Fault** | **Degraded result** |
|---|---|---|
| Produced by | `error(message, reason)` | `output({ error: … })` |
| Stream | **stderr** | **stdout** |
| Exit code | **1** | **0** |
| Shape | `{ "ok": false, "reason": …, "message": … }` | the command's ordinary result object, with an added `error` key |
| Honors `--json-errors` | **yes** | **no** — it is a payload, not an error envelope |
| How a caller detects it | exit code | **inspect the payload** |
A **degraded result** means: *the command ran to completion and is reporting a condition through its
result.* It is not a process failure. The command succeeded at the job of determining that, for
example, the artifact you asked about is absent.
```console
$ gsd-tools state-snapshot # in a project with no STATE.md
{
"error": "STATE.md not found"
}
$ echo $?
0
```
Some verbs return a companion result alongside the key, which is the shape that makes the intent
clearest:
```console
$ gsd-tools roadmap get-phase --phase 1 # no ROADMAP.md
{
"found": false,
"error": "ROADMAP.md not found"
}
$ echo $?
0
```
This is a **ratified contract**, not an accident — see
[ADR-2980](adr/2980-payload-carried-error-is-a-degraded-result.md) for the decision and the blast
radius that drove it. It applies to **60 call sites across nine modules** — `state`, `verify`,
`workstream`, `frontmatter`, `commands`, `template`, `phase`, `roadmap`, and `gsd2-import`.
(Issues #2966 and #2980 record this as "42 sites"; that figure counts only the sites where `error`
happens to be the object's first key. See ADR-2980 for why the real number is 60.)
### Writing a correct caller
The obvious shell form is **wrong** for a degraded result:
```sh
# WRONG — the process exits 0, so this branch never runs
if ! gsd-tools state-snapshot > snap.json; then
echo "failed"
fi
```
Check both channels — the exit code for faults, the payload for degraded results:
```sh
if ! out=$(gsd-tools state-snapshot); then
echo "fault (exit non-zero)" >&2 # error() path
exit 1
fi
if err=$(printf '%s' "$out" | jq -er '.error // empty'); then
echo "degraded: $err" >&2 # output({error}) path
fi
```
### Four things that will surprise you
1. **`--json-errors` does nothing here.** It governs `error()` only. A degraded result is
byte-identical with and without the flag, and still exits 0.
2. **`--raw` is not uniform on this path.** Most sites pass no raw value, so `--raw` still yields
the JSON object rather than bare text — but eleven sites do pass one and behave differently.
Do not infer either behavior from `--raw` alone; check the verb.
3. **Not every degraded result is an absent artifact.** A missing required argument is reported the
same way — `gsd-tools state add-blocker` with no `--text` returns `{"error":"text required"}` and
exits 0. So is unusable input: `gsd-tools state advance-plan` against a STATE.md it cannot parse
returns `{"error":"Cannot parse Current Plan or Total Plans in Phase from STATE.md"}`, also exit
0. **The exit code does not distinguish absent from malformed from misinvoked** — see ADR-2980's
Consequences, where this is recorded as a known cost.
4. **`message`/`error` text is not stable.** Assert on structure and on typed `reason` codes, never
on prose. The rule in "Writing tests" below applies to both paths.
### Which one should new code use?
Prefer the **fault** path, or a result with a named field. ADR-2980 ratifies an existing population;
it is not a license to add a 61st `output({ error: … })` site. Where a verb needs to report a
non-fatal condition in its payload, prefer the shape `state update-progress` already uses — a named
field plus a reason, with no overloaded `error` key:
```console
$ gsd-tools state update-progress # STATE.md present, no Progress field
{
"updated": false,
"reason": "Progress field not found in STATE.md"
}
```
## Error code taxonomy
Codes are frozen constants in `gsd-core/bin/lib/core.cjs` under