ADR 0023 — Shipped strings are self-contained: no internal references in steering, error, or refusal text
- Status: Accepted
- Date: 2026-07-10
- Tracking: legacy tracker 2130
Context
madtea ships strings to users in three compiled-in forms: hook steering text
(the plugin’s PreToolUse scripts), error/refusal returns from the CLI and the
madt_* tools, and MCP tool/parameter descriptions. The house habit — natural
while the project was internal-only — has been to justify a rule by citing the
governance artifact behind it: “ADR 0009”, "the removed all=true, #1964",
"see #1862". Those citations resolve only inside the org: they point at ADR
files in this repo and issues on an internal forge.
madtea is heading to a public release. Every internal citation baked into a shipped string becomes, for any user outside the org, a reference that does not travel with the binary — noise at best, a broken pointer at worst. It also couples the artifact users hold (the binary) to governance artifacts they don’t (our decision log and tracker), so the string’s meaning degrades the moment the reference moves, renumbers, or stays private.
The same session that surfaced this (legacy tracker 2130) also showed why guard texts cite at all: the citation was standing in for the rationale. The fix is not to strip the WHY — an unexplained wall trains agents to probe variants — but to state it inline.
Decision
Every string shipped with the binary is self-contained. Steering text, error returns, refusal rails, and tool/parameter descriptions state the rule they enforce in plain language — one sentence of WHY, in the string itself:
- “raw git against a credentialed remote can leak the token”, not “see ADR 0013”
- “there is no stage-everything mode — declare the paths you mean”, not
"#1964" - “a foreign repo’s primary checkout is shared state madtea does not own”, not “ADR 0019”
Shipped strings never name ADR numbers, internal issue/PR numbers, internal
repo paths, org names, or internal hosts. Where a longer rationale exists, the
string may point at a shipping surface only — a madt_help topic or a
docs/ path that travels with the release — never at the decision log or the
tracker.
Exemptions:
- Dynamic caller context. Numbers interpolated from the user’s own state
(their PR in a rail like
reconcile=29, their issue numbers in a close message) are the user’s references, not ours. - Non-shipped text. ADRs,
docs/narrative, source comments, commit and PR messages, and test names cite ADRs and issues freely — that is where citations belong. This ADR’s own Tracking line is the pattern, not a violation.
Enforcement lands with legacy tracker 2130: a lint/test over the hook scripts and steer/refusal string constants rejects hardcoded internal-reference literals, and existing violations (including the tool-description surface) are swept in the same change.
ADR-number enforcement follows in #79 (internal/danglingref.ADRFindings,
TestNoADRCitationsInShippedStrings). It is scoped more narrowly than the
issue/PR-number lint on purpose: because this ADR exempts source comments — and
the whole-text scripts/ and hooks/ surfaces are dominated by legitimate
ADR-citing header comments a shell file’s lack of an AST cannot separate from
steering text — the ADR rule scans only Go string literals under internal/ and
cmd/ (comments already dropped by the AST walk) plus the generated reference
docs, minus a tiny allowlist of contributor-diagnostic files (ADRAllowedFile)
whose ADR-citing literals are gate/test/dev-only messages never shipped to a
user.
Consequences
- Guard texts get slightly longer but strictly more useful: the rationale an agent needs to generalize the boundary is in the text, for every user, in every deployment — including the org’s own agents, who no longer need the decision log open to understand a denial.
- Renumbering, rewording, or keeping private any ADR or issue can never silently degrade a shipped string.
- Writing a new guard forces articulating its rule in one plain sentence — a guard whose rationale cannot be stated inline is a guard whose rationale is not understood.
- The public-release sweeps get one less category to chase: strings are clean at the source instead of scrubbed at launch.
Alternatives considered
- Keep the citations. Rejected: they only resolve inside the org — the reference does not travel with the binary, and public users hold the binary.
- Ship a reference appendix (citation → explanation) with the release. Rejected: indirection that must be maintained forever so a string can avoid one sentence of plain language; the string is the right place for the rule.
- Scrub at release time instead of at the source. Rejected: a sweep is a one-time cleanup, not a constraint — the next guard written after the sweep reintroduces the coupling. The lint makes the constraint structural.
Relates to ADR 0009 (guards may be non-overridable, but their texts must carry their own justification) and ADR 0021 (steering text quality is part of guard design — a wall that teaches beats a wall that wedges).
Canonical source: docs/adr/0023-shipped-strings-self-contained.md in the madtea repo.