Internal Contracts
This page documents contracts that exist in code but nowhere else — the kind
of thing a new contributor has to reconstruct by reading several files at
once. It complements CLAUDE.md
(which covers day-to-day conventions) with the why does this work this way
detail for five specific seams. Written as part of Phase 6b of the
modernization plan; see analysis/the/MODERNIZATION_BRIEF.md in the repo for
the full phase history.
1. .rrt/ lock-file schema and ownership
Section titled “1. .rrt/ lock-file schema and ownership”All six lock/manifest filenames used under .rrt/ are constants owned by
state.py —
no command module defines its own filename string. Each lock has a path
helper (docs_lock_path, health_lock_path, tree_lock_path,
artifacts_lock_path, drift_lock_path, docs_map_lock_path) and, for the
four with drift-detection semantics, an *_is_current() comparator that also
lives in state.py.
| Lock file | Guarantees (what it snapshots) | Invalidated by | Owning command |
|---|---|---|---|
docs.lock.toml |
Hash of every extracted source-doc block (docs/extractor.py output) at generation time |
Any tracked source file’s extracted doc block changing | rrt docs generate --format toml (write), rrt docs check / rrt-docs-check hook (verify) |
health.lock.toml |
Per-check status (ok / obsolete / warning / error) from doctor, folder, and EOL checks, merged into one namespace |
A check’s status regressing to a higher severity than what’s locked (improvements are silently accepted — see health_lock_is_current) |
rrt doctor --snapshot (write), rrt doctor --check (verify); rrt folder check --snapshot and rrt eol check --snapshot merge their own checks in without clobbering others |
tree.lock.toml |
entry_count + a combined tree_hash of the project’s directory structure |
The tree hash differing from the snapshot (file/dir added, removed, or renamed) | rrt tree --snapshot (write), rrt tree --check (verify); also the rrt-tree-check hook at pre-push |
artifacts.lock.toml |
Per-file SHA-256 + size for every file matched by artifact_targets globs, plus an optional combined inputs_hash per target (for source→output staleness) |
A tracked file’s hash changing, a new file matching the glob, a locked file going missing, or (when inputs is configured) the input-file hash changing |
rrt artifacts --snapshot (write), rrt artifacts --check (verify), rrt artifacts --regenerate (runs each target’s generation command, then re-snapshots) |
drift.lock.toml |
SHA-256 of every tracked agent-facing surface file (.claude/settings.json, .claude/hooks/*.py, .github/copilot-instructions.md, .github/instructions/*.md, .github/skills/*/SKILL.md, .github/agents/*.agent.md) |
Any tracked surface file’s content changing, or a tracked file appearing/disappearing | rrt drift generate (write), rrt drift check / rrt-drift-check hook at pre-push (verify) |
docs_map.lock.toml |
Per-directory hash of the generated Purpose+Tree README block emitted by rrt docs map |
The generated block’s content changing (new/removed files in the directory, purpose-doc edits) | rrt docs map (write/update), rrt docs map --check / rrt-docs-map-check hook (verify) |
Two lock files intentionally overlap in subject matter but stay separate
files by design: docs.lock.toml tracks source-doc extraction blocks used by
the generated docs site, while docs_map.lock.toml tracks the generated
per-directory README blocks from rrt docs map — different generators,
different drift surfaces, so a change to one never marks the other stale.
Note: this repo does not currently configure [tool.rrt.docs.map] in
pyproject.toml, so rrt docs map / docs_map.lock.toml are inactive here
even though the mechanism ships in the package. drift.lock.toml is active
and regenerated by the rrt-drift-check/generate hooks.
2. Hooks↔CLI parser-spec sharing
Section titled “2. Hooks↔CLI parser-spec sharing”workflow/hooks.py’s main() dispatcher used to hand-synthesize a partial
argparse.Namespace for several hook subcommands, independently of the real
CLI’s register() defaults — the exact bug class behind issue #140 (a flag
rename in one place silently left the other’s default stale). Phase 5 of the
modernization removed this for three subcommands by extracting a shared
_add_*_arguments(parser) helper next to each command’s own register():
| Hook subcommand | Shared parser spec | Helper | Defined in |
|---|---|---|---|
drift-check |
Yes | _add_drift_lock_arguments |
commands/drift_cmd.py |
publish-snapshot |
Yes | _add_publish_snapshot_arguments |
commands/git_sync.py |
folder-check |
Yes | _add_folder_check_arguments |
commands/folder.py |
For these three, hooks.py builds its subparser by calling the same
_add_*_arguments() helper the CLI’s register() calls, then dispatches the
real parsed Namespace straight to the cmd_* function — there is exactly
one place flags and defaults are declared, so a flag rename can’t silently
diverge between surfaces.
Subcommands that still hand-synthesize a partial Namespace (drift risk
remains — the main() dispatch case block builds these directly instead of
parsing real flags):
tree-check(synthesizes a fulltreeNamespace with hardcodedcheck=True, strict=True, format="classic", etc.)config-validate,config-reference-check(synthesize acmd_configNamespace)changelog-lint(synthesizes acmd_changelog_lintNamespace)tag-check(synthesizes acmd_tag_checkNamespace)- the
docs-*family —docs-generate,docs-publish,docs-inject,docs-map-update,docs-map-check(each sets severalparsed.*attributes by hand after a minimal parse, rather than parsing a fullcmd_docsspec) docstring-suggest(synthesizes acmd_docs_suggestNamespace)artifacts-check,artifacts-generate,artifacts-snapshot(each setsparsed.snapshot/parsed.check/parsed.regenerate/parsed.strictby hand rather than sharingcommands/artifacts_cmd.py’s parser spec)
Invariant a contributor must maintain by hand for these sites: every
field the target cmd_* function reads via getattr(args, "field", default)
must be set to the same default the CLI’s own register() would produce,
enumerated explicitly in the case block. If commands/<x>.py’s
register() changes a default or adds a new getattr(args, ...) read,
the corresponding hooks.py case block must be updated in the same PR —
nothing enforces this automatically for the subcommands not yet listed in the
table above. Converting one of these to the shared-spec pattern is a
mechanical, low-risk follow-up (see the three done sites for the pattern).
3. MCP/CLI surface parity
Section titled “3. MCP/CLI surface parity”As of Phase 5, rrt_bump (MCP) and cmd_bump (CLI) call the same
pipeline stage functions from commands/bump.py per version group —
resolve_bump_target → apply_bump_files → update_changelog →
refresh_bump_lockfile / refresh_bump_generated_assets →
finalize_bump_git. An MCP bump and a CLI bump of the same repo, same
inputs, produce equivalent results: same files written, same changelog
promotion, same lockfile refresh, same release branch + commit. This fixed
the previous defect (D9) where MCP’s bump only rewrote version-target files
and silently skipped pins, changelog, lockfile, generated assets, and the
git branch/commit step entirely.
Every bump knob carries the same name, values and default on both
surfaces (decision D-4). The pre-release base is the first: [tool.rrt]
prerelease_base, the CLI’s rrt bump rc --base minor and MCP’s
rrt_bump(level="rc", base="minor") all accept patch, minor, major or
auto. With no base given, both fall back to the config, whose default is
the next patch. Both resolve it through resolve_prerelease_base, so
auto reads the same commits on either surface.
The version scheme follows the same rule. [tool.rrt] version_scheme, the
CLI’s rrt bump --scheme pep440 and MCP’s rrt_bump(scheme="pep440") all
accept semver, pep440 or calver, validated against
version.scheme.VERSION_SCHEMES. With no scheme given, both fall back to the
group’s config; unset there, the scheme is inferred from the primary target.
An unknown value is a config error in [tool.rrt], an argparse error on the
CLI and {"error": "scheme must be one of: ..."} on MCP.
rrt_publish_snapshot (MCP) now requires both dry_run=False and
confirm=True to force-push — if confirm is omitted or False, the call
always behaves as a dry-run preview regardless of dry_run. This mirrors the
CLI’s two-signal requirement (--yes-i-know-this-overwrites-remote-history
and not passing --dry-run) for the same destructive, history-rewriting
operation (fixes D4/D6).
Legitimate, intentional differences (API contract, not divergence):
- Return shape. MCP tools return typed Pydantic models
(
BumpGroupResult,PublishSnapshotResult, one per version group for bump) defined inmcp/models.py. The CLI’scmd_bump/cmd_publish_snapshotprint human-readable output viaui/and return anintexit code. Same underlying operation, different surface-appropriate result encoding. - Scoping.
rrt_bumpaccepts an optionalgroupparameter to bump a single version group in one call — the CLI’s--groupflag does the same viaOptions, but MCP additionally always returns oneBumpGroupResultper group processed (even on partial failure), whereas the CLI exits 1 on the first fatal error. This lets an MCP client see partial results across groups instead of an all-or-nothing exit code. - Progress reporting. MCP tools call
ctx.report_progress()/ctx.info()/ctx.warning(), which have no CLI equivalent (the CLI usesui/progress bars and stdout/stderr text instead).
4. Config source precedence & auto-detection
Section titled “4. Config source precedence & auto-detection”load_config(root) in config/core.py tries supported native config files
in a fixed order — CONFIG_FILE_CANDIDATES in config/model.py:
pyproject.toml—[tool.rrt]package.json— top-level"rrt"keyCargo.toml—[package.metadata.rrt]or[workspace.metadata.rrt].rrt.toml—[tool.rrt].config/rrt.toml—[tool.rrt](alternate path for the same TOML shape as.rrt.toml)
Multiple sources present simultaneously: load_config iterates
iter_config_files(root) — which returns only the candidates that exist —
in the order above, and returns the first one whose file contains a
recognized [tool.rrt] (or rrt / [package.metadata.rrt]) section. A
candidate file that exists but has no rrt config (e.g. a pyproject.toml
without [tool.rrt]) is skipped, not treated as an error, and the search
continues to the next candidate — the “missing config” error is only raised
once every existing candidate has been checked and none contained one.
There is no merging across sources: exactly one file’s [tool.rrt] config is
used; a second file’s config (if present) is ignored entirely.
Zero-config fallback: if no candidate file defines [tool.rrt],
autodetect_config() builds a synthetic config from recognizable project
files (PEP 621 [project].version, Poetry [tool.poetry].version,
package.json, Cargo.toml, or multiple simultaneously-detected version
files) without requiring any [tool.rrt] section at all.
The autodetected flag: every RrtConfig carries autodetected: bool.
When True, it signals to callers that the config was synthesized rather
than read from an explicit [tool.rrt] section, and every user-facing
command that loads config checks it to:
- print a
(auto-detected)source label instead of the config file path (rrt config,rrt doctor,rrt release,rrt bump) - emit
format_autodetected_config_notice(...)as an informational/warning line describing which version targets were inferred and how to pin them explicitly withrrt init(rrt bump,rrt ci-version) - run an extra
check_autodetected_version_consistency(...)guard inrrt bumpthat fails the bump if the auto-detected version sources disagree with each other — a check that doesn’t apply to explicit config, since an explicit[tool.rrt]config declares its version targets deliberately
5. Cross-surface exit-code / output contract
Section titled “5. Cross-surface exit-code / output contract”Exit codes. Every cmd_* function across commands/*.py returns 0 for
success and 1 for failure — confirmed by spot-checking commands/bump.py,
and consistent across the package (no cmd_* function returns any other
integer). cli.py’s main() return value is passed straight to
sys.exit(...). workflow/hooks.py’s main() follows the identical
convention and is wired to the rrt-hooks console-script entry point in
pyproject.toml. The one exception outside this convention is argparse
itself: a malformed invocation (unknown flag, missing required subcommand)
raises SystemExit(2) from argparse before any cmd_*/hook handler runs —
this is Python’s argparse default and is not overridden.
Output streams. ui/messaging.py’s printer methods (DryRunPrinter,
the hooks-internal VerbosePrinter, and the free functions in
ui/color.py/ui/messaging.py such as error()/warning()/info())
default to stdout for normal/status output and only go to stderr
when a call site explicitly passes stream=sys.stderr. The rrt-ux-contract
convention (see the rrt-ux-philosophy skill) is: failure/error lines go to
stderr, informational and success lines go to stdout — call sites are
responsible for passing stream=sys.stderr at the point they render an
error, the printer classes don’t infer it from ok=False automatically.
ui/color.py’s free-standing error() function does default its own
stream parameter to sys.stderr when none is given — the two conventions
(hook printers vs. the color-level error() helper) agree on the outcome
(errors on stderr) but not on where the default lives, so a new call site
that wants stderr must pass it explicitly when going through DryRunPrinter/
VerbosePrinter.
emit_failure (hooks.py). emit_failure(title, details) -> int is the
canonical way a hook handler reports a hard failure: it prints title via
VerbosePrinter.line(..., ok=False, stream=sys.stderr) (error glyph + error
color, forced to stderr), prints each details entry via
.action(..., stream=sys.stderr), and unconditionally returns 1. Hook
handlers that want the standard failure rendering call return emit_failure(...) directly; only run_changelog_check and a few other
run_* validators construct their own output and return their own 0/1
without going through emit_failure. There is no equivalent single helper
on the CLI side — CLI commands build their failure output ad hoc using the
same ui/ primitives.
6. Published-docstring skeleton
Section titled “6. Published-docstring skeleton”Some docstrings are the product. Module docstrings and module-level doc
constants in this package are not internal comments. docs/publisher.py
renders them into published pages under docs/src/content/docs/, so editing
one changes the website. A doc reaches publication by one of two routes:
- an explicit
SOURCE_OWNED_TOPIC_DOCStuple exported by the module, whose slug is then wired intoTOPIC_PAGE_OUTPUTSorCOMMAND_DOC_SOURCES - the
inspect.getdoc()fallback inrender_command_docs, which picks up any command module that has no explicit entry
Both routes are governed. 19 declared entries plus 20 fallback modules cover
34 distinct documents, because five docs (artifacts, docs, fields,
install, skill) are reachable both ways and are validated once.
The skeleton. docs/skeleton.py enforces these rules whenever
[tool.rrt.docs.skeleton] is configured:
| Rule | Detail |
|---|---|
| Required sections | ## Overview, ## Examples, ## Caveats, ## Related docs, spelled exactly |
| Opening | ## Overview is the first H2 |
| Closing | ## Caveats then ## Related docs are the last two H2s |
| Optional sections | permitted only between Overview and Caveats |
| Non-emptiness | every required section needs content; a fenced code block counts |
| Heading depth | H3 maximum |
| H1 ownership | forbidden for slugs whose page lives under commands/, where _ensure_primary_h1 injects one from TITLE_OVERRIDES; required elsewhere |
| Canonical source | a substantive __doc__ may be extended or re-titled by a constant, never replaced |
| Collection coverage | every module declaring SOURCE_OWNED_TOPIC_DOCS must appear in publisher’s collection tuple |
| Register | per-section mean ≤ 22 words/sentence, ≤ 3.5 em-dashes per 100 words |
Errors surface in layers. Missing-section errors are reported first, and ordering is only checked once all four sections exist. Adding the last missing section can therefore reveal a new ordering error on the same file.
“Substantive” means more than one paragraph. A one-line summary docstring
is a summary, not documentation, so a constant may replace it freely. Anything
that continues into a second paragraph is an explanation, and an explanation
that never reaches the page is content no reader will see. A module whose
docstring is deliberately internal opts out with the rrt:docs-exempt marker,
the same marker tests/commands/test_commands_docstrings.py uses.
integrations/mcp_server.py is the current example: its docstring explains
module placement and import discipline, and MCP_SERVER_DOC is the published
text.
Register measurement is deliberately narrow. Only narrative paragraphs count. Headings, fenced code, tables, blockquotes and list items are excluded, because they are routinely sentence fragments and averaging them alongside prose is what makes naive readability metrics meaningless on reference documentation. Sections with fewer than four prose sentences are skipped entirely, since a mean over three fragments is noise. The 22-word ceiling was calibrated against this repo’s own corpus, whose per-section distribution runs median 11.8 and p95 20.5, with one outlier at 26.0.
Two validators, different jobs. skeleton.validate_skeleton reads the
source doc before headings are re-levelled, so it can name the module and
line a contributor must edit. publisher.validate_generated_page reads the
rendered page and owns only page-level invariants: frontmatter, exactly one
H1, and headings clipped at the H6 cap. It deliberately does not look for
sections, because generate_group_reference_markdown splices several commands
into one page and the renderer no longer knows where one module’s block ends.
Where it runs. Both feed _cmd_publish’s consistency_issues list, which
writes to stderr and returns 1 before any file is written. rrt docs publish --check is already a CI gate in cicd.yml and pages.yml, so the skeleton is
enforced in CI with no workflow changes. There is no rrt-hooks subcommand and
no action.yml input for it; rrt-docs-publish already covers it.
Unconfigured means off. validate_skeleton returns [] when
DocsConfig.skeleton is None. This repo opts in with a bare
[tool.rrt.docs.skeleton] table, since the dataclass defaults are its own
contract. Expressing “off” as an empty required_sections tuple is wrong — it
conflates an absent table with a deliberately disabled one.
Badge families are intentionally complete for the current icon registry across platform, registry, and language labels; see src/repo_release_tools/tools/platform.py if you think one is missing.
Chat is powered by Context7, a third-party service with its own terms and privacy policy.