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:
- Rates, not counts. Every pressure divides by the population it was drawn from. The 0.5.0 model counted absolutely and therefore scored repository size: Django, pytest, black and eight others all scored 0.0/F while a 53-file toy scored 4.6/A.
- Absence is not a value. A count the report does not carry produces an unmeasured aspect, never a zero. Six audit rounds were spent on individual instances of this before it was stated as a property.
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.
- ~~The work order bounds the agent by instruction, not by construction~~ — resolved (2.1.0).
_conformance.pycompares a diff against the paths the report named, throughgit_toolsin assembly, and reaches scoring not at all: whether a diff was obedient is not evidence about the code's condition.--conformance <revspec>reports it;--fail-on-out-of-scopegates on it. The one judgment in it is the test-pairing rule — a test added for a fix stays in scope though the work order never named the test file — because the alternative is a check that punishes the behaviour the product asks for. - ~~A suppression closes a finding~~ — resolved (2.1.0).
SUPPRESSION_MARKERSreads# noqa,# type: ignore,eslint-disable,pragma: no coverand skipped tests out of the diff, on the paths the report named. It is deliberately a second, separate verdict:conformantanswers "did it stay in scope",cleananswers "and did it silence nothing". Merging them would let a change that obeyed the work order and gagged a finding inside it read as a pass. Detection is diff-scoped by construction — a marker already in the tree is somebody's earlier decision, not this change's dodge. - ~~There is no attestation artifact~~ — resolved (2.3.0).
_attestation.pycomposes the record in assembly from the finished report, renders in presentation beside the other emitters, and reaches scoring not at all — it carries the estimate the audit produced and computes no verdict of its own. Its digest is over its own content, so it is reproducible; it states in its own text that it is not signed, because nothing here holds a key. A question nobody ran renders as not asked, and an incomparable ratchet as not established; neither is allowed to render as passed. - ~~Regression is gated per finding, not per dimension~~ — resolved (2.2.0).
_ratchet.pyreads dimension scores across the persisted history (ADR 009) with aTOLERANCEof 0.05, and returns three outcomes rather than two: held, regressed, and not comparable. The third exists because two scans taken under different calibration cannot be differenced, and it reuses the same rule assegments()— a comparison across an instrument change is not a comparison.--fail-on-regressiongates on it. - ~~Nothing compares two runs of the same transformation~~ — resolved (2.6.0).
_run_comparisonjoins scan history into one transformation's series, in assembly for the same reason as_conformanceand_ratchet;_history_viewrenders it, so joining and presenting stay split. It is the only one of the five integrity records that cannot fail a build — "run seven moved further than run six" is worth knowing and is not a thing to block a merge on. Two constraints shape it. History schema 4 carries atransformationlabel the operator supplies, deliberately outsideCOMPARABILITY_FIELDSso a series is never broken for saying its own name. And it reports the movement across an interval, never the transformation's effect: nothing in a tree says which transformation produced it, so the label is a claim this tool records without verifying, and everything else that happened in that interval is inside the number. The rendered section says so. - The integrity checks read a diff's shape, never its correctness. Named because the four resolutions above are easy to over-read: they establish that a change stayed where it was told, silenced nothing, and moved no dimension backwards. They establish nothing about whether the change works. The attestation says so in its own rendered text rather than leaving a reader to infer it, and no future item here should quietly widen that.
-
~~
cleandid not see oracle-weakening that is not a suppression directive on a named file~~ — resolved (3.11.0), Decision 12._oraclereads every test file in the diff for a skip or suppression, an assertion that cannot fail, the assertion API replaced, a failing assertion swallowed, and assertions deleted with nothing in their place; any of them failsclean. Still shape, in assembly beside_conformance, and held to the precision bar frozen before it was written (tests/test_oracle_weakening_precision_bar.py: zero false alarms on its legitimate edits).tools/prove_falsifiers.pyis this repository's revert-proof for cited tests; it is not a layer and not a customer-facing step. Freeze, a fresh review seat, and a human on the test hunk are outside this package. -
~~Scoring consumes raw dictionaries~~ — resolved (ADR 001 stage 4).
score_reportnormalizes at its entry and every layer below it takes typed evidence. The.get(name, 0)fallbacks and theunmeasured_dimensionscompanion list are deleted: a pressure is now computed only fromMeasuredinputs, so there is no default left to forget to guard. - ~~The compatibility grade is still banded from the pessimistic floor~~ — resolved (stage 8).
score.gradeis removed;verified_gradeis the only letter a report carries, and it is null when the evidence does not support one. - Stage 5 is implemented:
score.evidence_statusandscore.verified_gradeship. The compatibility fields are gone (stage 8). ADR 002 is rejected because it assumed--fail-on-gateconsumes the grade; the shipped flag checks hard findings only, so no CI policy changed. The requirement list for thedefault-v1profile is frozen in_verification.pyrather than derived from the typed model — deriving it let a new field silently change what the name demanded. - ~~
docs/standard.mdmixes genres~~ — resolved. The empirical studies moved to studies.md; the standard now holds only the rubric, its calibration method, and the reference corpus. Mixing them was the documentation shape that let a Tier 3 claim read as settled. - History window materialization is not separated from analysis. ADR 001 stage 9.
- ~~The scale was still calibrated against the built-in path~~ — resolved (3.6, 2026-08-14). The point estimate uses the analyzers' reading for every dimension they measured on all three declaration criteria; a partial concept set is unmeasured, so the built-in detectors remain the fallback.
CALIBRATION_Cwas re-derived against that mix, and again when the reference corpus was re-measured for 1.0 (2.6279 → 2.2658 → 5.8843)._calibration.pyis the source of truth for the current value, andtools/calibration/measure.py --checkfails the build on drift, so this note names the history rather than pinning a number that ages. The interval still widens to contain whichever source did not supply the point. - ~~The band matrix does not drive the score~~ — resolved (3.2). Live scans store per-unit band pressures;
_pressuresuses them for declarations and file size so CCN 16 and 45 are not the same fact. Hard gates stay binary. Count-rate fallback remains only for summaries written before the wiring (tests/test_band_pressures.py).CALIBRATION_Cwas not re-derived: the existing calibration tests still hold. - The proposed
_analyzerspackage and_conceptsregistry were never created as named modules. Their roles landed in_tool_adapters,_metric_adapters,_verdict_adapters,_generic, and_corroborate. This document names the files that exist. - Declaration extraction is gated on
DECLARATION_SUFFIXES(Python, Java, Kotlin, C, C++, C#, Swift, Go, Rust, PHP, Ruby, Shell, COBOL, free-form and fixed-form Fortran, and JS/TS/HTML). A suffix outside that set can be included for length, duplication and risk; it does not produce a declaration population.--changed-onlyand--fail-on-newno longer have the defects previously listed here: a thin diff withholds (ADR 005;test_scan_scope.py), while baseline v3 and recurrence resolve structured identities through_finding_match. The visible label staysfunction:{path}:{name}#{ordinal}; scan-timebody_digestresolves same-name reorders and git-attested renames resolve path moves. - ~~The presentation layer lacked the three ADR 011 skins~~ — resolved (8.3–8.6). Chat/CLI Markdown, a Markdown file and one self-contained HTML file render the same report dictionary. TTY asks every invoke; flags win; CI never asks. Chat first-run setup persists a presentation default; a per-call choice or the host's own ask wins. MCP returns the bounded view and saves a chosen markdown or html report to the location the person chose (ADR 011, amended 2026-09-27). Its other write boundary is the repository config, user config, user state, repository scan history at
.maintainability/history.jsonland repository baseline at.maintainability/baseline.json. - ~~History records lacked the series required by the HTML charts~~ — resolved (8.1–8.2). New JSONL records use schema 3: the schema-2 chart fields remain, and structured identities sit beside labels. Schema-1/2 records still load as explicit gaps; an existing history file gains each later successful scan.
- ~~The chat surface lagged the terminal surface~~ — resolved (D1–D17).
The primary chat/MCP path now covers the pool, structured setup and grants,
history consent and recurrence, baselines, format-specific payloads,
environment remedies, and the same bounded advice as the CLI.
tests/test_chat_primary_docs.pykeeps README, product intent, MCP instructions, the portable skill, chat help, IDE guidance, and generated instruction packs ordered around that primary flow. Source-read and artifact-read analyzers compose in one report (tests/test_d15_composition.py). The chat-surface defect register retains D1–D17 as closed history. - Zero-install scanners are written, never cloned.
java_declaration_rangesfinds methods, constructors and types and bounds each at its own body;c_declaration_ranges(1.1.0) does the same for C functions andstruct/enum/uniontypes,cpp_declaration_ranges(1.2.0) for C++ functions, class members, namespaces and templates,csharp_declaration_ranges(1.3.0) for C#, andfortran_declaration_ranges(1.4.0) for free-form Fortran program units — the first language here with no braces at all. None of them invents a bounding rule: all sit on_ranges_core, which owns the one rule the design rests on — a range is bounded by its own body, never by where the next declaration starts. Every suffix set is inDECLARATION_SUFFIXESand the default include list, sopip installwith no lizard measures them. The 2026-08 wording here said there would be no further scanners; the C++ (1.2.0) and C# (1.3.0) increments amend it. The rule it was protecting stands and is the price of admission: a language is claimed only when it has a scanner of its own, a documented set of things it misses, and tests that pin them — never by aiming the last-resort regex at it, which matchesdef/function/arrows and would report a confident zero. Go, Rust and everything else stay recognized and withheld until analyzers supply a declaration population (ADR 006). Feeding lizard intodeclarations_scannedwithout the full concept set would still move mixed-language scores in silence.
Extension points
- A new detector belongs in scanners, returns findings, and adds a count to
report_summary. It becomes scored only by being given weight in_formula.CATEGORY_ASPECTS; an aspect with no weight fails the build. - A new output format belongs in presentation and reads the report dictionary. It must not compute a score.
- A new external analyzer is a leaf adapter in
_tool_adapters(or a declared tool in the catalog) that_analysisinvokes through_runner. SARIF ingest (--sarif-input) remains a side-channel and does not feed the point estimate. A SARIF the developer's own pipeline wrote, declared inanalyzers.pipeline_results, is read by_pipeline_resultsinto the coverage section: it examines its declared concerns for the languages it names and, in its first slice, does not score (Decision 17). A metric adapter that completes a concept set does:_primary_pressuressubstitutes that reading. Per adapters, do not pretend every analyzer has the same semantics.
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:
- ADR 003 specifies a type-aware semantic-analysis path. Language-native analyzers and checked-in repository policy may produce universal findings, configured policy violations, or explicitly non-gating design-review candidates. They do not get to modify rubric weights or grade bands.
- ADR 004 v1 is shipped
(
tests/test_economic_context.py). Recurrence and hotspot churn plus an optional configured labor range produce a labeled low/base/high scenario block and may reorder the work order. They do not feed scoring or grading. See the ADR's v1 slice for the ask set, persist path, and env overrides.
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.