Files
msd-core/docs/how-to/adopt-the-v2-exit-contract.md
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

7.7 KiB

How to adopt the v2 exit contract

msd-tools runs, by default, under exit-contract v1 — every observable exit code is byte-identical to every prior release. v2 is an opt-in projection, per ADR-3889 §4, that turns a small set of previously same-looking outcomes into distinct, non-zero exit codes a CI gate can branch on without parsing stdout. This page covers how to turn it on, what the new codes mean, and how to migrate a gate that today treats "any non-zero exit" as fatal.

Should you turn this on?

Turn it on if a script or CI gate wraps msd-tools and needs to tell "ran fine", "you called it wrong", "there was nothing to find", "a prerequisite was missing", "it crashed", and "it ran but is reporting a condition in its payload" apart from the exit code alone, instead of parsing JSON on stdout to find out. If your caller only ever needs pass/fail, v1 already gives you that — there is nothing to adopt.

Step 1 — turn it on

Either a flag or an environment variable activates v2. The flag beats the env var if both are given in the same invocation.

# Per-invocation (preferred in test code and one-off scripts):
node msd-tools.cjs --exit-contract=v2 state validate --strict

# Process-wide (preferred for CI and shell wrappers):
export MSD_EXIT_CONTRACT=v2
msd-tools state validate --strict

The flag works in any argv position — before the subcommand or after it — and msd-tools strips it before dispatch, exactly as it already does for --json-errors.

Only the exact lowercase tokens v1/v2 are accepted. Anything else present — a typo, V2, v3, or an explicitly empty --exit-contract= — throws rather than silently falling back to v1; a selector for a contract whose whole point is "nothing fails with success" must not itself fail open. That throw surfaces as a stack trace at exit 1, not as a tidy USAGE 64, and that is deliberate: the failure is the version being unresolvable, so there is no contract version yet under which to project a code. Loud and coarse beats quiet and wrong. An empty MSD_EXIT_CONTRACT= (nothing after the =) reads as unset, not as an explicit selection, so a shell that exports it empty still gets v1.

Step 2 — read the code table

v2 projects a declared outcome through one generated registry (msd-core/bin/lib/exit-code-registry.cjs). Every code below is stable and machine-checked; do not hardcode the integers in your own scripts — call exitCodeFor(name) if you are writing Node, or just compare against the number after reading it here once.

Code Name Meaning
0 (PASS) The operation ran and its verdict is affirmative.
1 (FAIL) The operation ran and its verdict is negative — the honest default when nothing more specific applies.
64 USAGE Caller error — bad argv, unknown subcommand, missing required argument.
66 NO_INPUT Ran; zero units were in scope, and that emptiness is known to be genuine.
69 UNAVAILABLE Could not run — a prerequisite was absent, unreadable, or scope was never established.
70 INTERNAL Self-failure — the run itself broke (crash, timeout, killed subprocess), not its inputs.
80 DEGRADED Ran to completion and is reporting a condition through its result payload, not failing as a process.

2 is reserved to the Claude Code hook-protocol deny and is never produced by msd-tools itself — you will not see it from a CLI invocation.

Step 3 — understand 80 specifically

80 (DEGRADED) is the one code most CI authors get wrong, because it is the one code where "ran to completion" and "found a problem" are the same event. It fires when a command's JSON payload carries a serializable error key — for example msd-tools state-snapshot in a project with no STATE.md returns {"error": "STATE.md not found"}. Under v1 that exits 0; under v2 it exits 80.

80 is not a crash. The process did its job: it determined, correctly, that the artifact you asked about is absent, unparseable, or that a required argument was missing — see ADR-2980 for the full population this covers (64 call sites across nine modules) and why it stayed a payload-carried signal rather than a thrown fault. 70 (INTERNAL) is the code for an actual crash. Do not conflate the two: a gate that maps 80 to "the tool is broken" will page someone for a condition the tool successfully diagnosed.

Step 4 — migrate a gate that treats any non-zero exit as fatal

The naive shell form,

if ! msd-tools state-snapshot > snap.json; then
  echo "msd-tools failed" >&2
  exit 1
fi

is correct as a fail-safe under v2 — every registered code is non-zero, so this can only turn a false green red, never a red green (ADR-3889 §5). What it cannot do on its own is tell you which non-zero condition fired, which matters if your policy is "treat DEGRADED as a soft warning but still hard-fail on USAGE/UNAVAILABLE/INTERNAL":

set +e
msd-tools --exit-contract=v2 state-snapshot > snap.json
code=$?
set -e

case "$code" in
  0)   ;;                                            # PASS
  80)  echo "degraded result — inspect snap.json" >&2 ;;  # ran, reported a condition — your call whether this gates the pipeline
  64|66|69|70)
       echo "msd-tools failed (exit $code)" >&2
       exit 1
       ;;
  *)   echo "msd-tools failed (unrecognized exit $code)" >&2
       exit 1
       ;;
esac

Whether DEGRADED itself should gate your pipeline is a policy decision only you can make — the contract's only promise is that 80 is distinguishable from 64/66/69/70, not that it is always safe to ignore. A gate that wants the old, coarser behavior (any non-zero is fatal, including 80) needs no case statement at all; the naive form above already does that correctly.

Four things that will surprise you

  1. --json-errors is a different, orthogonal switch. It governs whether error()'s stderr envelope is JSON or plain text; it does nothing to output()'s exit code. You can run v2 with or without --json-errors.
  2. Precedence can surprise a caller reading only output()'s contract. An explicit main() return, or a non-zero process.exitCode a command set directly, always wins over a DEGRADED declared earlier in the same invocation — projection can only raise a code, never lower one. See docs/json-errors.md for the full precedence order.
  3. The declaration does not accumulate. If a command calls output() more than once — a diagnostic degraded payload followed by a clean final one — only the last call's declaration is live when the process exits.
  4. v1 and v2 are the only recognized versions today, and the default flips to v2 at the next major (ADR-3889 §4). Pin --exit-contract=v1 explicitly in a script that must keep today's codes indefinitely, rather than relying on the current default staying v1 forever.
  • ADR-3889 — the exit-code registry and the versioned projection this page walks through
  • ADR-2980 — why output({error}) exits 0 under v1, and the population DEGRADED covers under v2
  • docs/json-errors.md — the full reference for both failure channels, the error-code taxonomy, and the outcome-declaration precedence rules
  • Resolve a raw-terminator finding — the sibling page for the lint rule that keeps every termination path routed through this same seam