maintainability-agent docs
Reference

Architecture

How the package is layered, which layer may depend on which, and the invariant each one owns.

This describes the code as it is, not as intended. Where reality falls short of a decision, it is listed under Known debt rather than described aspirationally. Direction of travel is kept in the decision register; the explicitly labeled proposal section at the end maps those decisions onto the current layers without claiming they ship.

Network boundary (as-is). This agent does not transmit the audited tree and does not call an LLM. _runner is the only spawn point for analyzers besides git_tools. Those children are not network-sandboxed; this process does not police their sockets. _npx may run npx --yes when analyzers.acquire_tools is set and a Node binary is missing — acquisition, not analysis, still the internet. There is no seccomp / unshare -n wrap. See P1.

The layering is enforced by tests/test_architecture.py, which reads the real import graph. A rule stated here and not enforced there is decoration, and this document tries not to contain any.

Layers

Dependencies point downward only. No cycles. Every module in a box is a file under src/maintainability_audit/. tests/test_architecture.py fails the build if a layer set names a module this diagram omits.

flowchart TB
  subgraph entry["entry"]
    cli["cli"]
    _arguments["_arguments"]
    _first_run["_first_run"]
    _mcp_setup["_mcp_setup"]
    _setup_persist["_setup_persist"]
    _setup_errors["_setup_errors"]
    _mcp_audit["_mcp_audit"]
    _mcp_gate["_mcp_gate"]
    _mcp_resources["_mcp_resources"]
    _mcp_grants["_mcp_grants"]
    _mcp_refusals["_mcp_refusals"]
    _skill_install["_skill_install"]
    _self_update["_self_update"]
    _mcp_servers["_mcp_servers"]
    _precommit_install["_precommit_install"]
    dunder_main["__main__"]
    mcp_server["mcp_server"]
    _grant_ledger["_grant_ledger"]
  end

  subgraph presentation["presentation — reads the report dict"]
    renderers["renderers"]
    prompts["prompts"]
    _prompt_sections["_prompt_sections"]
    _hostile_prompt["_hostile_prompt"]
    sarif["sarif"]
    baseline["baseline"]
    _evidence_view["_evidence_view"]
    _scan_view["_scan_view"]
    _coverage_notes["_coverage_notes"]
    _html_view["_html_view"]
    _html_report_sections["_html_report_sections"]
    _markdown_sections["_markdown_sections"]
    _charts["_charts"]
    _work_order_view["_work_order_view"]
    _security_work_order["_security_work_order"]
    _economics_view["_economics_view"]
    _semantic_view["_semantic_view"]
    _history_view["_history_view"]
    _identity["_identity"]
    _tdd_view["_tdd_view"]
    _precommit_view["_precommit_view"]
    _in_loop_view["_in_loop_view"]
  end

  subgraph assembly["assembly — builds the report, calls the scorer once"]
    report["report"]
    _analysis["_analysis"]
    _pipeline_results["_pipeline_results"]
    _documents["_documents"]
    _analyzer_sections["_analyzer_sections"]
    _economics["_economics"]
    _environment["_environment"]
    _built_ins["_built_ins"]
    _work_order["_work_order"]
    _handoff["_handoff"]
    _gates["_gates"]
    _conformance["_conformance"]
    _oracle["_oracle"]
    _ratchet["_ratchet"]
    _run_comparison["_run_comparison"]
    _precommit["_precommit"]
    _in_loop["_in_loop"]
    _attestation["_attestation"]
    _work_order_weights["_work_order_weights"]
    _backfill["_backfill"]
  end

  subgraph scanners["scanners"]
    metrics["metrics"]
    _discovery["_discovery"]
    _delegated_pillar["_delegated_pillar"]
    _security_delegate["_security_delegate"]
    _banner["_banner"]
    _operator_reads["_operator_reads"]
    _practice["_practice"]
    duplication["duplication"]
    deadcode["deadcode"]
    idioms["idioms"]
    similarity["similarity"]
    history["history"]
    _adapters["_adapters"]
    _generic["_generic"]
    _metric_adapters["_metric_adapters"]
    _ratio_adapters["_ratio_adapters"]
    _verdict_adapters["_verdict_adapters"]
    _jvm_adapters["_jvm_adapters"]
    _tool_adapters["_tool_adapters"]
    _selection["_selection"]
    _semantic["_semantic"]
    _semantic_ts["_semantic_ts"]
    _test_pairing["_test_pairing"]
    _test_honesty["_test_honesty"]
    _spectables["_spectables"]
    _test_execution["_test_execution"]
    _test_commands["_test_commands"]
  end

  subgraph scoring["scoring"]
    scoring_mod["scoring"]
    _aspects["_aspects"]
    _pressures["_pressures"]
    _criteria_scope["_criteria_scope"]
    _evidence_reader["_evidence_reader"]
    _pillars["_pillars"]
    _trends["_trends"]
    _recurrence["_recurrence"]
    _formula["_formula"]
    _calibration["_calibration"]
    _anchor["_anchor"]
    _bands["_bands"]
    _corroborate["_corroborate"]
    _second_source["_second_source"]
    _verification["_verification"]
    _derive["_derive"]
  end

  subgraph boundary["boundary"]
    evidence["evidence"]
  end

  subgraph parsing["parsing"]
    source["source"]
    declarations["declarations"]
    _cognitive["_cognitive"]
    _ranges_core["_ranges_core"]
    _ranges_js["_ranges_js"]
    _ranges_java["_ranges_java"]
    _ranges_c["_ranges_c"]
    _ranges_cpp["_ranges_cpp"]
    _ranges_csharp["_ranges_csharp"]
    _ranges_fortran["_ranges_fortran"]
    _ranges_swift["_ranges_swift"]
    _ranges_kotlin["_ranges_kotlin"]
    _ranges_shell["_ranges_shell"]
    _ranges_go["_ranges_go"]
    _ranges_rust["_ranges_rust"]
    _ranges_php["_ranges_php"]
    _ranges_ruby["_ranges_ruby"]
    _ranges_cobol["_ranges_cobol"]
    _tokens["_tokens"]
    _xml["_xml"]
  end

  subgraph foundations["foundations"]
    _metrics_types["_metrics_types"]
    _grammar["_grammar"]
    _masking["_masking"]
    _hotspots["_hotspots"]
    config["config"]
    _config_defaults["_config_defaults"]
    _running_version["_running_version"]
    _user_config["_user_config"]
    _stored_grants["_stored_grants"]
    _safe_write["_safe_write"]
    _walk["_walk"]
    git_tools["git_tools"]
    _scan_history["_scan_history"]
    _finding_match["_finding_match"]
    instructions["instructions"]
    _runner["_runner"]
    _catalog["_catalog"]
    _changed_code_policy["_changed_code_policy"]
    _semantic_policy["_semantic_policy"]
  end

  entry --> presentation
  presentation --> assembly
  assembly --> scanners
  assembly --> scoring
  scoring --> evidence
  scanners --> parsing
  scoring --> parsing
  parsing --> foundations

_bands sits in scoring because it is rubric data. Declaration and file-size pressures use it (3.2). _finding_match owns structured identity and matching; _identity presents the ordinal labels derived from the report. _user_config owns the XDG user configuration and per-repository seen state; it belongs beside config in foundations. _safe_write is the single way the agent writes into a tree it is auditing — bounded, symlink-refusing, staged and renamed so no existing inode is opened for writing — and sits in foundations for the same reason: every layer that writes must reach it, and it reaches nothing but config (D34). _walk is the single way the agent walks that tree: it never enters .git, never follows a symlinked directory out, and treats a directory that vanished mid-walk as empty, so a worktree git adds during an audit cannot abort it. _semantic classifies type-backed findings; _semantic_ts supplies TypeScript type facts from recordings or an already-installed tsc; _semantic_policy loads the optional checked-in policy.

Chat, MCP, and an interactive CLI TTY are one setup: the same questions. MCP is the chat transport, not a third questionnaire. _first_run and _mcp_setup are two transports of setup_questions, not two processes. CI and a non-TTY never ask.

Layer Owns May import
foundations Data types, config defaults, git invocation, masking primitives, _finding_match (structured identities plus the one matching relation used by gates and recurrence), and _semantic_policy nothing internal except _finding_match using git_tools for rename evidence
parsing Reading files once, extracting declarations, complexity, ranges, tokens. Declaration ranges are one module per language — _ranges_js, _ranges_java, _ranges_c, _ranges_cpp, _ranges_csharp, _ranges_fortran — over _ranges_core, whose scan_bounded walks all of them identically; each module supplies only its patterns. Bounding is the one language-shaped part of that walk and is injected: braces by default, and Fortran (1.4.0) supplies an end-keyword finder, because the rule that a range never runs past its own body is what is shared, not the mechanism enforcing it. Dispatched by the declarations.SCANNERS table (1.1.0). Adding a language is a module and a row, and touches neither the core nor the dispatcher foundations
scanners Producing findings: sizes, duplicates, dead code, idioms, near-duplicates, history, ADR 003 semantic classification (_semantic, _semantic_ts), and — via _adapters — whatever the external analyzers report; _selection composes the runnable set from the language inventory (D15) foundations, parsing, _runner
scoring Turning findings into aspects, categories, an overall, a grade, and whether the evidence supports verifying it (_verification) foundations, parsing (types only), the evidence boundary
assembly Running the scan, running the analyzer pool (_analysis), reading the SARIF the developer's own pipeline wrote (_pipeline_results, Decision 17) and stating what it found (_documents), the environment work order composed from coverage (_environment), recording the built-in detectors as their own source tier (_built_ins), ordering the work by risk against effort and recomputing each item's worth (_work_order, weights in _work_order_weights), deciding which of those items an agent is handed and which are withheld for a design decision (_handoff, split from _work_order at its file gate — D208), assembling the report dict, invoking the scorer once anything below. _conformance compares a diff against the work order the report already produced — or against an ask the operator supplies (--ask, Decision 13) — and _oracle reads the diff's test files for the shapes that make a suite unable to fail (Decision 12) — assembly because it composes from a finished report rather than measuring a tree, and it may never reach scoring: whether a diff was obedient is a fact about an agent, not evidence about the code's condition. _ratchet compares the newest scan with the previous comparable one, refusing to subtract across an instrument change — two scans taken under different calibration cannot be differenced, and _scan_history.segments already owns that rule
presentation Markdown/chat, a self-contained HTML report, PR comment, SARIF, baseline, remediation prompt, _hostile_prompt (ADR 013's adversarial brief, split from prompts at the file-size line), _evidence_view (shared estimate/range/evidence/verified-grade wording), and _identity (labels and digests consumed from the report, never source). The renderers consume one report dictionary and do not score foundations, presentation helpers, the report dict, and _handoff, _work_order's presentation-facing prompt selection. _attestation is the fourth emitter on the prompt seam: it composes the conformance and ratchet records into one reproducible per-change artifact — the record a code-generating agent cannot honestly produce about its own output, because an author is not the independent check on their own work
entry Argument parsing, transport, output routing, exit codes, and first-run setup — one question set, two transports (_first_run on a CLI TTY, _mcp_setup over MCP elicitation; chat is MCP, not a third set) — and the MCP audit tool itself (_mcp_audit, split from mcp_server) and the D10 root-grant machinery (_mcp_grants, same split; grants are a server boundary, not setup) and the setup/run gate that answers with questions instead of a report (_mcp_gate) and the MCP resources, which answer reads and can never ask (_mcp_resources) and the set of refusals the transport may declare (_mcp_refusals, shared by both seam-binding modules so neither has to import the other) and the packaged-skill sync (_skill_install) and the self-update that installs a newer release and re-syncs that skill (_self_update, D230) and the record of running MCP servers it stops when stale (_mcp_servers, D232) — the one layer where the tool may ask a question anything below. _gates is the post-audit half of the CLI, split when cli.py crossed the 500-line file gate: it composes from a finished report — did the diff obey its work order, did a dimension slip since the last comparable scan — and turns those into a process exit code. It measures nothing and may not reach scoring

The rules, and why each exists

Each rule was bought by a specific failure. They are enforced, not advisory.

1. Scoring may not import scanners or assembly. The rubric must not be able to reach back into how a finding was produced. If it could, a repo-specific special case would eventually be written and P2 (one uniform rubric) would fail silently.

2. _formula and _calibration import nothing internal. They are the judgment layer — weights, bands, grade gates, the calibration constant — and they are data. A leaf cannot acquire a dependency on scanning, so the rubric cannot come to depend on what it is scoring.

3. Presentation may not import the scoring internals. renderers, prompts, and sarif consume the report dictionary. They may import presentation helpers, foundations such as _hotspots and config, and prompts may import _handoff for its presentation-facing prompt selection — a carve-out that named a boundary no module enforced until _handoff existed. They do not import the scoring layer or compute a score. This keeps one path to a score: if a renderer could compute one, two numbers could disagree, which is the class of bug that produced an overall contradicting the categories printed beside it.

4. evidence imports nothing internal. The normalization boundary is deliberately a leaf. Everything it needs arrives as an argument. This is what makes it a boundary rather than another participant — see ADR 001 §3.

5. The calibration derivation calls the shipped scorer. _derive imports _aspects, _formula, and _pressures and runs the same functions a live report runs. It may not restate the rollup. Three consecutive audits found the derivation differing from the live path by exactly one step — category rounding, then the untested cap, then per-aspect rounding — and each time the corpus median survived while the per-repository claim did not. "The same pipeline" is only true when there is one pipeline.

6. No import cycles. One existed: _derive needed the scorer and the scorer needed the calibration, so _derive imported from inside a function body. Splitting scoring into _pressures / _aspects / scoring removed it. Cycles are how layering rots without anyone deciding to rot it.

7. Only _runner runs analyzers; only _runner, git_tools and _backfill may spawn a process. Adapters describe invocations; they do not perform them. _backfill talks to git directly rather than through git_tools — a remaining inconsistency, not a third analyzer runner. _test_execution (Class 5) is not a fourth spawn point: it runs the operator's opted-in test command through _runner, so the one execution path the tree can reach still funnels through the single runner (Decision 9, amended 2026-08-31). Neither is _security_delegate (D177): it runs secure-code-agent for the security pillar through _runner.run, with the auditing interpreter as the command and every report redirected out of the tree.

8. Analyzer adapters may not import scoring. The same rule scanners already live under, for the same reason: an adapter that could see the rubric would eventually be tuned to it.

9. Corroboration happens before the evidence boundary. evidence receives already-combined values with their strength and provenance. The boundary stays a leaf and stays the single normalization point; it does not learn about tools.

10. _practice may not read source files. Practice level is a claim about enforcement, not about code. If it could read source, it would drift into being a second, uncalibrated condition score. It reads configuration and CI wherever they live — a coverage gate in a nested api/pyproject.toml, a decisions folder that is not named adr* — through the same exclusion the scan uses, so a vendored manifest is not read as this repository's gate. Nested config is still configuration, not source (plan-81dc6870 Class 3).

11. Practice level and code condition are never combined. No function returns their average. ADR 007 invariant 2 exists because a single composite number would destroy both.

12. Only the transport declares anticipated refusals. The plain functions keep raising the domain types — InvalidAuditArgument, PathNotAllowed, SetupRequired, StaleBaseline, PolicyError and ConfigUnreadable — because the CLI and every library caller depend on them. At the entry seam, the transport translates only named anticipated refusals into the SDK's declared forms: ToolError from the audit tool, ResourceError from the report resource and its security validator, with the message reaching the caller. Everything else stays a crash: the caller gets Error executing tool <name> or a bare resource URI, and the traceback stays server-side so nothing internal leaks. The list lives in _mcp_refusals.py as ANTICIPATED_REFUSALS, re-exported from mcp_server — it sits below both seam-binding modules because mcp_server imports _mcp_resources, so a tuple beside either would be a cycle for the other. It is named types only, never bare ValueError: modules below the transport raise those with internal paths in the message. It is also the whole set of refusals a caller can act on — a type missing from it stops teaching just as surely as an undeclared one, which is why StaleBaseline belongs there: it travels this path for real, from _baseline_workflow through findings_not_in_baseline and load_baseline_identities to _read, and its message tells the caller to regenerate the baseline. PolicyError is in the tuple on different grounds, and the difference is stated because this sentence first claimed the same grounds for both and was wrong about one. PolicyError cannot presently reach any of the three seams: every site that raises it — load_catalog, settings_from, resolve_pool — is reachable only through _analysis.analyze(), which catches it and returns Analysis(error=...) instead of raising, exactly as that function's docstring promises. It is declared ahead of a seam-level configuration path that does not exist yet. Dropping it would be defensible today and would silently lose the remedy on the day such a path appears, because the derivation test below would by then count the type as already accounted for. ConfigUnreadable joined the tuple when the security branch introduced it: a configuration file that exists and cannot be parsed is a refusal the caller can act on, and its message names the file they pointed at. It carried a second path for a while — the interpolated OSError appends the OS filename, which for a symlinked config is the target — so it now reports strerror alone. EvidenceValidationError is excluded outright: on this path the report is built internally, so a failure in it is an internal bug. A reviewer eyeballing that list got it wrong twice in opposite directions; test_every_named_exception_is_a_declared_refusal_or_excluded classifies every named exception in the package (in the tuple, or excluded with a stated reason) and test_the_transport_excepts_the_named_tuple_not_a_copy holds the three seams to the tuple by name, across every transport module rather than one — it counted seams in mcp_server alone until the resource bindings moved out from under it. Before any of this was declared, mcp 2.1.0 correctly classified every boundary refusal as a crash, so the refusal still refused but stopped teaching its remedy.

Data flow

flowchart LR
  disk["files on disk"] --> src["SourceIndex — each file read once, parsed once"]
  src --> scan["scanners — findings and counts"]
  scan --> summary["report_summary — populations and finding counts"]
  summary --> pressures["dimension_pressures — rates over their populations"]
  pressures --> aspects["aspect_scores — 14 aspects, 0-5 or None"]
  aspects --> categories["categories — five ISO means, rounded as displayed"]
  categories --> estimate["estimate + range — unknowns priced 0 and 5"]
  estimate --> grade["verified grade — banded from the interval floor, or null"]
  grade --> report["report dict + schema_version"]
  report --> out["renderers / prompts / sarif / baseline"]

Two properties of this flow are load-bearing and each has a test:

Invariants and where they are enforced

Two columns, deliberately. Property means the test varies the real input space or the real field set, so a case nobody thought of is still covered. Regression means it pins specific scenarios — valuable, but it only proves what it enumerates. An audit found this table claiming enforcement on the strength of test names, with a promise mapped to a corpus-median test that never checked the arithmetic it was cited for. The distinction is now stated rather than implied, and test_architecture.py fails the build if a test named here does not exist.

Invariant Enforced by Strength
Layering and acyclicity above test_architecture.py Property — reads the real import graph
Withholding any single summary input cannot raise the floor or the grade test_withholding_any_single_input_cannot_raise_the_floor_or_the_grade Property over summary keys; regression only for history, which is removed as a whole block rather than field by field
maintainability_range always contains maintainability_estimate test_the_interval_always_contains_the_score Regression — five named configurations
The estimate equals the weighted mean of the printed categories test_the_overall_is_the_weighted_mean_of_the_printed_categories Regression over six reports, including untested and unknown-bearing ones
Every advertised aspect carries weight somewhere test_every_scored_aspect_carries_weight_in_some_category Property — compares the declared aspect set against the weighted set
Derivation agrees with the live scorer test_derivation_matches_live_score_report_repo_by_repo Property over all 40 corpus repositories
Absence never resolves into a better-defined state test_deleting_a_field_never_resolves_it_into_a_better_defined_state Regression — one field
Concealing any required input withholds verification, never raises the floor and never improves the grade test_concealing_any_required_node_withholds_verification Property — all 28 typed inputs, from production evidence
Restoring a concealed input recovers the model, the grade and the score exactly test_restoring_a_concealed_node_recovers_everything Property — all 28 typed inputs
An aspect whose input is concealed reports unmeasured, never a re-estimate test_an_aspect_that_uses_a_concealed_input_is_unmeasured_not_re_estimated Property — dependency derived by sweeping, not declared
Every scored aspect can be unmeasured by concealing some required input test_every_scored_aspect_is_unmeasured_by_concealing_some_required_input Property — coverage over the aspect set
Fully measured evidence collapses the range test_fully_measured_evidence_collapses_the_range Property — on a fixture where every aspect is measured
Complete NotApplicable evidence contributes no score or uncertainty test_not_applicable_is_excluded_instead_of_priced_as_clean_or_unknown, test_not_applicable_is_complete_evidence Regression — rollup arithmetic and a production young repository
Measured(0), Unknown and NotApplicable survive a JSON round trip test_the_three_states_remain_distinguishable_across_a_json_round_trip Regression — one of each state
Every declared invariant violation fails closed test_every_declared_summary_subset_is_enforced, test_every_declared_summary_sum_is_enforced, test_every_declared_history_subset_is_enforced Property — iterate the shipped relation tables and assert the specific validator fired
The whole sweep is deterministic test_the_whole_sweep_is_deterministic Property — byte-identical documents across runs
Unknown production evidence is never resurrected test_unknown_production_evidence_is_never_resurrected Regression — one field
Impossible values and subset violations are rejected test_a_value_no_scanner_could_produce_is_rejected, test_a_subset_count_larger_than_its_set_is_rejected Regression — enumerated field kinds and one cross-field pair
History window is independent of cache depth test_fix_breadth_window.py Property over four cache depths
Only the transport declares anticipated refusals test_every_named_exception_is_a_declared_refusal_or_excluded, test_the_transport_excepts_the_named_tuple_not_a_copy, test_a_stale_baseline_leaves_the_seam_as_a_declared_refusal, test_the_seams_own_argument_refusals_leave_it_declared, test_an_unconfigured_repository_refuses_through_the_resource_seam Property — every named exception in the package; the three seams except the tuple by name; every member that can reach a seam driven through it — ToolError plus __cause__ at the tool, which bypasses the SDK, and at the resource the crash wrapper excluded on 2.1.0 and the refusal's own text on 2.0.0

Recursive variation across the whole typed model now exists (tests/test_evidence_properties.py, ADR 001 stage 6): every one of the 28 typed scoring inputs — 23 summary, 5 nested history — is concealed one at a time from evidence generated by build_report, and restored, with the results scored through the production seam scoring.score_evidence. Cases are derived from walk_evidence, so a field added to either evidence dataclass is swept the day it is added.

Human-facing rendering of these states has shipped. Markdown, prompts and PR comments go through _evidence_view; SARIF reads the same score fields directly into its run-level property bag. All surface evidence_status and verified_grade.

Known debt

Stated rather than hidden, because an architecture document that only describes the good parts stops being usable.

Extension points

Where the design is going

What has not landed is listed under Known debt. Remaining direction of travel lives in target architecture. Status of each decision lives in the register. This file does not carry a second copy of either.

Decisions embodied in this document

Every design point above traces to a record. Nothing here is a preference someone remembered.

Design point Recorded in
FOSS analyzers produce the evidence; built-ins demoted to a labelled fallback ADR 006
Several tools per concept, combined with weights; spread becomes the interval ADR 006
Analyzer coverage always reported; unavailable is never a clean result ADR 006, P8
Metric emitters vs verdict emitters; only metric emitters supply denominators ADR 008
The rubric sets tool thresholds; project lint config never moves the score ADR 008
Measurements, counts and populations all survive to the report ADR 008
Band-matrix pressures drive declaration and file-size scores; gates stay binary ADR 008
The agent never calls an LLM; it produces that model's input ADR 008, P1
CLI for CI, MCP for chat, one core, no combined server ADR 008
The report is first-class; the CLI writes Markdown; the MCP report resource returns the byte-identical document ADR 008
Chat, Markdown file, and one HTML file are three skins of that report, and json emits the dictionary they read; ask every interactive invoke ADR 011
Work order with location, target, computed delta and verification ADR 008
No rate without a denominator that supports it, per aspect ADR 005, P7
Scan scope is part of the result; scope escalation over silent scoring ADR 005
Five pillars with declared scope; practice level never averaged with condition ADR 007
Risk × Effort ordering; Fill-Ins never above Quick Wins ADR 007
Recurring findings escalate to design-review candidates ADR 008
Depth and license policy select the pool; every deny wins analyzer pool, config
Selection by intent: the user answers concerns and density, the agent picks tools analyzer pool, config
Concern vocabulary comes from the scoring model; measures grows with adapters analyzer pool
Every finding stays in the report; ranking orders, never filters ADR 008
Raw analyzer output is retained for the model, especially on parse failure ADR 008
Work order is a queryable set; the consumer picks the slice ADR 008
Set deltas are recomputed, never summed from per-item deltas ADR 008
Coverage gaps reported per language and concern, with the prerequisite named ADR 006
Availability proven by invocation; acquisition is off unless the user enables it in user-tier configuration ADR 006
Scans append to a durable history; trends over comparable records only ADR 009
Finding labels use path + declaration name + same-name ordinal, never a line number; matching also uses scan-time body digests and git-attested renames ADR 009
Trends describe past scans; forecasting stays forbidden ADR 009, product intent

MCP ships all three primitives: two tools expose the audit and describe its boundary, resources expose the rubric, analyzer catalog and byte-identical Markdown report, and the maintainability-agent prompt supplies the bounded slash command. get_agent_info is read-only; audit_repository saves a chosen markdown or html report to the location the person chose (ADR 011, amended 2026-09-27) and otherwise may write exactly five local artifacts: the repository config, user config, user state, repository scan history at .maintainability/history.jsonl and repository baseline at .maintainability/baseline.json, never source or a report. Users can start it with maintainability-agent mcp; the maintainability-agent-mcp console script remains for existing IDE configurations. CI does not go through MCP — a protocol hop between a runner and an exit code costs determinism and buys nothing. There is no combined server with secure-code-agent, because independent releasability is worth more than cross-tool synthesis today.

The first two ADR 006 rows describe the landed shape. The point estimate uses analyzer measurements where the full concept set was measured; the constant was re-derived against that mix (3.6). _identity keeps the function:{path}:{name}#{ordinal} label while _finding_match owns structured comparison. Scans append through _scan_history. Practice and condition stay separate numbers.

--analyzers makes P1 conditional on pinned versions on a given platform; the report records the versions that produced it. Runtime rises from milliseconds to seconds or minutes. A tool can be absent, wrong-versioned, slow, crash, or emit unparseable output — each is a distinct Unknown reason, and none may fail the run or improve a score. Built-ins stay labelled as the fallback tier via _built_ins.

Extension boundaries

ADR 004 v1 ships. ADR 003 option C is accepted and its TypeScript-only increment is current work. Neither may change what the standard score means:

The intended dependency direction is:

flowchart TB
  analyzers["language-native analyzers / SARIF"] --> semantic["_semantic — normalized semantic findings"]
  policy["_semantic_policy — checked-in semantic policy"] --> semantic
  measures["repository measurements"] --> economic["economic impact scenarios"]
  context["configured economic context"] --> economic
  semantic --> work["bounded prioritized work order"]
  economic --> work

Both paths terminate in report data consumed by presentation. Neither may reach backward into _formula, _calibration, _aspects, or grade policy. ADR 004 v1 is in the tree (_economics, _economics_view). ADR 003's TypeScript increment uses _semantic for type-backed classification, _semantic_ts for type facts, and _semantic_policy for the optional checked-in policy. _semantic and _semantic_ts belong with scanners; _semantic_policy belongs with configuration foundations. Their production change also places them in the layer list enforced by tests/test_architecture.py. Neither path may reach _formula, _calibration, _aspects, or grade policy.