maintainability-agent docs
Audits and registers

Defect register: the chat surface

Genre: defect register. Recorded 2026-08-16 after the first external field test of the MCP server through an AI chat host. Every entry records a defect against requirements that already exist — in ADR 006, ADR 004, ADR 009, product-intent.md, or the operator's stated requirements — none is a feature proposal. The required behavior column cites the document that already requires it. D1–D17 are retained as closed, tested history.

Context

The primary user surface is an AI chat host (an IDE assistant or chat window driving the MCP server), not the terminal. At the field test, nearly every capability the terminal path had — configuration, analyzer execution, history, economics, format choice — was absent or silently degraded on the chat path. The theme across all seventeen entries is one defect class: capability wired to the TTY, viewer shipped to the primary surface.

The falsifier standard

Thirty of the entries below are one defect. Not thirty defects with a family resemblance — one mechanism, fixed thirty times without being named, which is why it kept coming back. It was found by asking why instead of closing the thirty-first.

The shape, every time:

  1. A universal claim exists. "No analyzer reads configuration from the audited tree." "Every git command disables housekeeping." "No document presents a refused analyzer as runnable."
  2. An audit finds one instance where the claim is false.
  3. The check is written from the instance, not from the claim.
  4. The check goes green, and the claim is still false everywhere else.

Step 3 happens because the author has a reproduction in hand and no enumeration of what the claim quantifies over. It surfaces two ways: the population is hand-picked — two adapters of fifteen, three sentences, one file of a package — or the property is approximated by a string instead of executed: "pytest" in job, "42" not in document, the presence of a diff command that had been neutered with || true.

Mutation testing did not catch it, and the reason is the useful part. Mutation was being applied — to the instance that motivated the fix, which is inside the sample the check was written from. So the mutation confirmed the sample and said nothing about the claim. Every time an auditor broke one of these, their mutation came from outside the sample and the author's came from inside it.

So a falsifier for a universal claim owes three things:

Clause Enforced by
Derive the population from the source of truth — ADAPTERS, an rglob, the catalog — never a list typed by hand review; the shape is visible
Assert the population is not empty, so a sweep that matched nothing fails instead of passing tests/test_falsifier_standard.py
Mutate outside the sample: the proof must break a member the test does not name stated in *Mutation:*, required from D97

The third cannot be checked mechanically, so it is required to be written down. An author who has to say which member they broke, and why it sits outside what the test names, cannot make the substitution silently.

Entries

D1 — Closed: analyzer pool execution is config-driven at every seam

The FOSS analyzer pool now follows analyzers.run through build_report, the CLI, the MCP audit_repository tool, and the MCP report resource. build_report and the MCP tool accept a tri-state decision: an omitted value follows the loaded configuration, while an explicit true or false wins for that call. The CLI's --analyzers and --no-analyzers flags likewise override configuration in either direction. A configured repository therefore runs its pool without a per-invocation flag; built-in detectors remain the fallback where the pool cannot supply a complete scored dimension (ADR 006).

Closing tests: test_build_report_resolves_the_pool_tristate_at_its_own_seam, test_cli_runs_or_suppresses_the_pool_at_the_production_seam, test_mcp_audit_runs_or_suppresses_the_pool_at_the_production_seam, and test_mcp_report_resource_uses_the_repository_pool_decision in tests/test_pool_runs_by_config.py.

D2 — Closed: first-run setup ships on the chat path

An unconfigured repository audited over MCP now receives one structured setup set covering analyzer depth and license policy, pool execution, scan history consent, economic context and report format. Accepted answers are written to the repository and user configuration tiers. Declined or unsupported elicitation returns the same questions in setup_needed; while both configuration tiers remain absent, later calls ask again. Only written answers end setup elicitation.

Amended 2026-08-22 (superseded in part by D26 and D27). This entry closed on answers that "apply to that same audit", and that clause is struck: answering setup configures the repository and returns the run-or-reconfigure choice, never a report. What D2 actually settled — that first contact asks, in structured form, and persists to both tiers — still holds. The closing citation below is restamped to the test that carries it now; the original name is kept in D27, which records the rename and why.

Report save location is a separate host question at save time. The local MCP process returns report text and never writes a report file; the host asks where to save it when the user chooses a file presentation.

Closing tests: test_setup_triggers_only_when_both_configuration_tiers_are_absent, and test_one_native_elicitation_configures_and_then_asks_before_auditing in tests/test_first_run_elicitation.py; test_a_completed_mcp_audit_marks_seen_even_when_a_gate_fails in tests/test_setup_precondition.py; plus test_unanswered_setup_is_reelicited_until_answers_are_written and test_repeated_declines_keep_returning_the_same_setup_needed_block in tests/test_first_run_reelicitation.py.

D3 — Closed: setup and the slash prompt use structured choices

First-run chat setup uses one structured MCP elicitation with disclosed options and defaults, including chat as the presentation default. A decline or host without elicitation support receives those same structured questions in setup_needed. The maintainability-agent slash prompt now tells the host to use its structured elicitation or question UI for presentation, with chat pre-selected, instead of prescribing a free-text ask.

Amended 2026-08-22 (superseded in part by D26). This entry closed on "and the audit proceeds on documented defaults", which is struck: that audit was the defect D26 removed, and an unanswered call now returns the questions and nothing else. D3's actual subject — that the questions are structured choices with disclosed defaults, on both the elicited and the handed-back path — is unaffected. The second closing citation is restamped to the test's current name.

Closing tests: test_setup_questions_are_structured_choices_with_disclosed_defaults in tests/test_first_run_elicitation.py, test_declined_or_unsupported_elicitation_returns_questions_not_an_audit in tests/test_setup_precondition.py, plus test_slash_prompt_uses_structured_presentation_choice_with_chat_default in tests/test_consent_and_grant.py.

D4 — Closed: documentation teaches the primary chat surface first

README, product intent, and the MCP server instructions now state the same surface contract: an AI chat host driving local MCP is primary, while the CLI is the automation and CI surface. README presents the chat workflow before CLI usage instead of making the primary path an appendix.

Closing test: test_readme_product_intent_and_mcp_description_name_the_surface_contract in tests/test_chat_primary_docs.py.

D5 — Closed: scan history accrues over MCP

audit_repository follows the CLI's history rule through a tri-state record_history decision. First-run setup asks whether to record scan history and persists the yes-default answer to both configuration tiers. With the parameter unset, that answer can start a series; an existing history remains a standing answer and appends on every successful audit. Capability alone starts nothing, an unconfigured headless first call writes nothing, and explicit true or false wins in either direction. The returned report reads the resulting series into scan_history and computes rename-aware design_review_candidates from its latest comparable segment.

Closing tests: test_existing_history_appends_on_a_plain_mcp_call, test_elicitation_capability_alone_does_not_start_mcp_history, test_headless_first_call_does_not_create_mcp_history, test_record_history_tristate_overrides_both_directions, test_mcp_report_exposes_history_and_design_review_candidates, and test_cli_and_mcp_reports_agree_over_the_same_history in tests/test_mcp_history.py; consent is closed by test_setup_asks_for_history_consent_and_persists_it_to_both_tiers, test_history_consent_drives_the_accepting_call, and test_persisted_history_consent_precedes_the_standing_file_rule in tests/test_consent_and_grant.py.

D6 — Closed: chat remediation targets are recorded

The MCP path returns a remediation prompt by default. Whenever that audit writes a scan record, its targeted tuple stores exactly the delivered prompt's prompt_targets, in the same identity space as the scan's findings; if the caller suppresses the prompt, it stores no targets. Chat history can therefore distinguish recurrence from the stronger "told, fixed, returned" signal in ADR 009.

Closing tests: test_mcp_history_records_the_delivered_prompt_targets, test_mcp_records_only_advice_delivered_after_current_scan_escalates and test_cli_records_only_advice_delivered_after_current_scan_escalates in tests/test_mcp_history.py.

D7 — Closed: baseline adoption ships over MCP

audit_repository now accepts a repository-scoped baseline path and can write the CLI's version-3 baseline format. The default standing location is .maintainability/baseline.json. When that file or an explicit baseline is present, new_findings carries the sorted fingerprints not matched by its structured identities; git-attested renames do not become new findings. gate_passed remains the hard-gate result and is not changed by baseline membership.

Closing tests: test_mcp_baseline_round_trip_survives_git_mv_and_names_only_new_findings and test_mcp_baseline_defaults_inside_root_and_rejects_escape in tests/test_mcp_baseline_payload.py.

D8 — Closed: requested format governs the MCP payload

Every result keeps the run metadata, while format selects one report presentation: JSON returns the report dictionary; chat and Markdown return Markdown; HTML returns HTML plus the Markdown chat fallback required by ADR 011. The bounded remediation prompt is included by default and can be omitted with include_prompt=false; omitted advice is recorded as no targets. The report resource reads stored history without appending and renders the same document as the CLI over that series.

Closing tests: test_requested_format_governs_the_mcp_payload, test_suppressing_prompt_records_no_targeted_advice and test_report_resource_matches_cli_over_stored_history_without_appending in tests/test_mcp_baseline_payload.py.

D9 — Closed: missing-tool remedies reach the chat user

When selected analyzers cannot run, every MCP format now carries a top-level environment_work_order beside its one D8 findings body. Each item names the tool, its install command and the concepts installation would restore, and the server instructions tell the host to surface that order. A chat or HTML caller does not need the JSON-only report dictionary to discover missing evidence.

Closing tests: test_missing_analyzers_surface_a_top_level_environment_work_order in tests/test_consent_and_grant.py (chat), plus test_environment_work_order_reaches_every_format in tests/test_grant_only_user_tier.py (json, markdown, html).

D10 — Closed: out-of-roots access has a structured grant

The MCP allow-list still defaults to the server's launch directory. When an audit tool request falls outside it and the client can elicit, one structured choice offers this session (pre-selected), always, or no. A session grant extends only the running process. An always grant writes an allowed_roots entry to the user configuration tier and is merged at server startup; repository configuration never carries that authority. Refusal or a client without elicitation receives PathNotAllowed with both --allow-root and MAINTAINABILITY_MCP_ALLOWED_ROOTS remedies. Report resources remain ask-free and read-only.

Closing tests: test_session_root_grant_is_default_and_lives_only_for_that_server, test_always_root_grant_persists_only_to_the_user_tier_and_loads_at_startup, test_denied_or_unsupported_root_grant_explains_both_static_remedies, and test_report_resource_never_elicits_or_persists_a_root_grant in tests/test_root_grants.py.

D11 — Closed: economic context is part of chat setup

The ADR 004 labor question now rides the structured first-run chat setup. It is declinable; accepting persists the same low/base/high loaded_engineering_cost_per_hour shape to both configuration tiers, while declining omits economic context from both.

Closing tests: test_apply_answers_persists_economics_and_format_to_both_tiers and test_declining_economics_omits_the_block_from_both_tiers in tests/test_first_run_elicitation.py.

D12 — Closed: the operator skill teaches the chat-primary flow

The shipped skill checks configuration, uses the host's structured question UI for elicited choices, keeps the bounded work order in chat, and writes no report file without a chosen location. CLI commands are retained under the automation and CI path rather than presented as the default user flow.

Closing test: test_shipped_skill_teaches_chat_setup_before_cli_automation in tests/test_chat_primary_docs.py.

D13 — Closed: the XDG user configuration and state tier ships

Configuration now merges built-in defaults, the XDG user configuration, and repository maintainability-agent.json in that order, so the later repository tier wins. Any loaded configuration tier enables the D1 pool default unless the winning tier explicitly sets analyzers.run false. The XDG state file records repositories by absolute root, making first-run detection persistent; corrupt or unreadable user files are treated as absent rather than crashing an audit.

Closing tests: test_user_paths_honor_xdg_environment, test_user_and_repository_config_merge_in_precedence_order, test_any_loaded_config_tier_defaults_the_pool_on_and_explicit_false_wins, test_corrupt_user_config_reads_as_absent and test_successful_cli_audit_marks_the_repository_seen in tests/test_user_config_tier.py. Named individually because "the whole suite" is not a falsifier: an audit tightened the citation check on 2026-08-23 and this entry was the one closing on a bare path, which tells a reader where to look but never which test fails if the tier stops being honoured (D33).

D14 — Closed: the chat integration applies setup and root grants

The chat integration is built: one native MCP elicitation applies analyzer, depth, license, history-consent, economics and presentation answers and persists them for later calls. Out-of-roots tool requests now ride the structured grant path, and the slash prompt delegates presentation to the host's structured question mechanism.

Amended 2026-08-22 (superseded in part by D27). "to that same audit" is struck here for the reason given in D2: the elicitation configures the repository and the call returns the run-or-reconfigure choice. This entry also cited its falsifier in prose rather than under a Closing test marker, which is how its citation survived a rename unnoticed — the marker is now mandatory and machine-checked.

Closing tests: test_one_native_elicitation_configures_and_then_asks_before_auditing in tests/test_first_run_elicitation.py, the grant tests in tests/test_root_grants.py, and the consent and slash-prompt tests in tests/test_consent_and_grant.py.

D15 — Closed: analyzer selection is goal-directed

The entry as recorded: selection was policy filtering plus language applicability, and the gap analysis that knows which concerns are unmeasured for which languages never drove a run. The requirement — restored here verbatim after an audit caught the close rewriting it — was that selection consults the same language inventory and concern→concept mapping the coverage section uses, so a run covers the repository's languages with the verified tools available and names, before or with the results, what to install to close the remaining gaps.

That requirement now holds at the production seam: language-mismatched tools are stated inapplicable with the inventory's reason, engaged tools follow the tree's languages, a concern-narrowed pool engages exactly the tools that serve it, and the environment work order names the installs — with the concepts they restore — that close the per-language gaps the coverage section states. The composition of source-read and artifact-read shapes (identity, path normalization, staleness, artifact-gated applicability, class_dirs isolation) was pinned in the same track.

Selection composes the runnable set in _selection.select_runnable before any probe or spawn: a language-mismatched tool is never attempted, a catalogued tool this project cannot invoke is stated as no-adapter rather than counted runnable, and coverage.selection carries the disjoint runnable and inventory_filtered sets.

Closing tests: test_inventory_deselects_before_any_probe_or_spawn, test_selection_composes_the_runnable_set_before_the_run_loop, test_the_runnable_set_is_minimal_for_the_trees_languages — the minimality proof, which reads the coverage document's real by_language key after an earlier version read a key that never existed and passed itself on the empty result — and test_a_catalogued_tool_without_an_adapter_is_not_called_runnable in tests/test_d15_goal_directed.py; plus test_one_report_composes_source_read_and_artifact_read and test_stale_artifact_evidence_is_stated_on_the_composed_summary in tests/test_d15_composition.py, the two-shape composition pins.

Round-four verification on 5a7857c: reopened. A catalog tool with no adapter is still returned as Selected when its catalog languages overlap the tree, and the report consequently lists it under coverage.selection.runnable with a no-adapter outcome even though it can neither be probed nor spawned. The report's own description says every member of runnable was probed or spawned. The minimality regression does not detect this class: it reads the absent coverage["languages"] key instead of scored_languages / by_language, then explicitly passes whenever that empty set is observed. The queue item is to make the reported sets state their actual semantics and replace the vacuous assertion with a production-report falsifier.

D16 — Closed: chat workflow help is linked from both entry surfaces

The chat workflow help now explains what the agent does, first-run setup and roots grants, history consent, analyzer-primary evidence and built-in fallback, and how to read the report's estimate, range, grade, history, recurrence, baselines and economic scenario. It lives beside the docs the existing lints sweep and is reachable from README, the documentation index, the integration guide, and the MCP server instructions.

Closing test: test_chat_help_is_complete_linked_and_reachable_from_mcp in tests/test_chat_primary_docs.py.

D17 — Closed: integration guidance and generated packs are chat-first

ide-agent-integration.md and the generated agent-instruction packs (--init-agent-standards) now teach the same sequence: check configuration, use structured elicitation, keep the report in chat unless the user chooses a file location, and treat the CLI as the automation and CI door.

Closing test: test_integration_guide_and_generated_packs_teach_chat_before_automation in tests/test_chat_primary_docs.py.

D18 — Closed: skill installation is bound, atomic, and complete

--install-skill opens the skill root once with O_NOFOLLOW|O_DIRECTORY and performs every read, write and unlink relative to that descriptor, so swapping the pathname afterwards cannot redirect a write. Three further holes closed after a second audit: a failed rebind of a freshly created root used to leave dir_fd=None, which resolves against the process working directory — it is now a refusal; a leaf replaced by a HARD link was written through, because O_NOFOLLOW says nothing about hard links — files are now staged and renamed into place, so the old inode is never modified; and os.write was called once, installing a truncated file while reporting success — every byte is written or the install refuses. Staging files are cleaned up on every failure path.

Closing tests: test_the_validated_root_is_bound_by_descriptor_not_by_name, test_missing_root_swap_never_falls_back_to_process_cwd, test_leaf_hardlink_swap_cannot_modify_external_file and test_short_write_is_completed_or_refused_without_success in tests/test_skill_install.py.

D19 — Closed: occupancy is every entry, not every regular file

Occupancy counted regular files and symlinks, so a root holding only an empty directory, a FIFO or a socket read as a fresh install and was modified without consent — and a FIFO named SKILL.md hung the installer forever, because reading it meant opening it. Occupancy is now any directory entry at all, decided from stat metadata with follow_symlinks=False so no special file is ever opened. Forced sync removes what it understands (files, symlinks, FIFOs, sockets, empty directories) and refuses by name anything it does not, rather than deleting on a guess. An empty root directory is still a fresh install.

Closing tests: test_a_nested_symlink_in_an_empty_root_still_needs_consent, test_empty_subdirectory_counts_as_occupied, test_fifo_counts_as_occupied_without_blocking, test_socket_counts_as_occupied and test_force_refuses_or_safely_removes_special_entries in tests/test_skill_install.py.

D20 — Closed: a configured history path cannot escape its repository

paths.history is read from a file inside the repository under audit, and every consumer built root / configured without validating the result. A repository could therefore name ../outside.jsonl, an absolute path, or a path through a symlinked directory, and an audit with history enabled would create and append that file outside the authorized root — reproduced through the public MCP seam. One helper, config.repository_path, now resolves and bounds every configured repository-scoped path before any existence check, mkdir or append, and all five construction sites across the MCP tool, the MCP report resource and both CLI paths use it. Traversal, absolute escapes and symlink escapes are one comparison, not three special cases, and the refusal is the same structured PathNotAllowed the roots boundary already used.

Closing tests: test_mcp_history_rejects_parent_traversal_without_external_write, test_mcp_history_rejects_absolute_escape_without_external_write, test_mcp_history_rejects_symlink_escape_without_external_write, test_a_history_path_inside_the_repository_still_records and test_the_cli_door_applies_the_same_boundary in tests/test_history_boundary.py.

D21 — Closed: the skill calls the tool instead of interrogating the repo

Found in the field on 2026-08-21. The skill's first step told the host agent to perform a configuration check — look for maintainability-agent.json, the user tier, local instruction files — before calling audit_repository. In a repository whose config had been deleted, that instruction produced a quarter-minute of deliberation and then a question to the operator about which config to use: a question the tool itself asks properly, as structured first-run setup, the moment it is called. The agent was doing the tool's job, worse and slower.

The first step is now the call. An unconfigured repository is not an obstacle to reason about; it is the case first-run setup exists for. Repository instruction files govern how findings are acted on, never whether the audit runs.

Closing test: test_the_skill_calls_the_tool_before_inspecting_configuration in tests/test_chat_primary_docs.py.

D22 — Closed: the delivery question offers every presentation the product ships

Found in the field on 2026-08-21, in the same run that produced D21. Asked to audit a repository, the host agent offered two choices — "chat only" or "chat plus save to a path you name" — and then asked where to write the markdown. The HTML report was never mentioned. The product ships three presentations (chat, markdown, html), the format argument accepts all three, and first-run setup already asks which one the user prefers, and the operator found the HTML report option simply missing.

Nothing in the product had lost the option. The skill's presentation step named only chat and "a location", so the agent filled the gap with an option set of its own invention — the D21 failure mode one layer down: improvising a question the product already asks properly. The MCP prompt had the correct three-way wording all along, which is why the defect was invisible to whichever surface a reviewer happened to read.

The skill now states the three presentations, routes the answer to format, names the two-option shape as the thing not to substitute, and makes the save location a second question asked only after a file format was chosen. Both instruction surfaces are checked together, and against the setup vocabulary rather than a copy of it, so a fourth presentation would have to reach the instructions to ship.

Closing test: test_every_delivery_surface_offers_all_three_presentations in tests/test_chat_primary_docs.py.

D23 — Closed: the analyzer catalog reaches an installed copy

Found in the field on 2026-08-21, in an audit of a private repository that reported analyzer catalog missing at /Users/…/Library/Python/3.11/lib/python/data/analyzer-catalog.json.

CATALOG_PATH climbed three parents from _catalog.py to a repository-root data/ directory. In a source checkout that resolves correctly, which is why 1,383 tests and every local run agreed it worked. From an installed wheel the identical expression points at <site-packages>/../data/analyzer-catalog.json — a path that has never existed anywhere. docs/standard.md, served over MCP as maintainability://standard, was resolved the same way and had the same fault.

Neither file was declared in any package, so neither was ever copied into a distribution. Every release from 0.1.0 through 0.9.1 shipped without them. The consequence is not cosmetic: with no catalog nothing can be selected, so the external analyzer pool — ADR 006's primary evidence source, and the entire JVM adapter track — was unreachable for every pip-installed user, who silently received built-in fallback numbers instead. The operator's field tests had been measuring the fallback, not the product.

Both assets now live in maintainability_audit/_assets/ and resolve package-relative. tools/build_catalog.py writes there.

Closing tests: test_the_built_distribution_carries_the_catalog_and_the_standard stages a real build and reads what came out — a green suite proved nothing here for nine releases because nothing ever looked at an artifact. test_no_runtime_asset_is_read_from_outside_the_package refuses the path shape itself, in any runtime module, whatever it is reaching for. Both in tests/test_wheel_contents.py.

D24 — Closed: analyzers_run reports the outcome, not the request

Found in the same field run. With the catalog missing (D23) no analyzer could be selected, let alone execute — and the result envelope still carried "analyzers_run": true, because the key echoed the resolved tri-state rather than what came of it. The report prose said fallback tier; a caller trusting the machine-readable envelope got a false green.

This is the repository's own absence-as-zero risk pattern one level up: capability recorded as result. The key's own comment claimed it existed so "a caller cannot mistake an audit that ran six built-in detectors for one that ran ten tools" — the promise was always the outcome reading; only the value was wrong.

analyzers_run is now true exactly when an analyzer contributed, read from the coverage document. The request is preserved beside it as analyzers_requested, and environment_work_order explains any gap between the two.

Closing test: test_a_requested_pool_that_contributes_nothing_is_not_reported_as_run in tests/test_pool_runs_by_config.py, which reproduces the cause — an unreadable catalog with the pool explicitly requested — rather than imitating the symptom.

D25 — Closed: the questions handed back are questions someone is told to ask

Reported by the operator on 2026-08-21, correcting a wrong diagnosis of D22: no prompt had ever offered HTML. Not in that run — in any run, for the life of the product.

D22 blamed the skill's presentation step, and fixing it was necessary but did not touch the cause. When a host cannot be elicited, the audit hands its entire first-run set back as setup_needed — including default_format with chat, markdown and html — and D3 records that as graceful degradation. But setup_needed was named in no instruction surface anywhere: not the MCP server description, not the skill, not the generated packs. Its sibling on the same degradation path, environment_work_order, was explicitly instructed on both ("surface it to the user"). One of the two keys was explained and the other was not.

So the question that offers the three presentations was generated correctly, returned correctly, and asked by nobody. An agent receiving a payload it had no instruction about did what agents do with a gap: it invented its own questions, which is what D21 and D22 each caught one symptom of. The user's report was the only detector that ever fired, because no test looked at the handed-back payload and no surface mentioned the key.

Both surfaces now instruct it: ask every question, offer exactly the options it lists and no others, then call again with the answers.

Closing test: test_the_handed_back_questions_are_instructed_and_carry_every_format in tests/test_chat_primary_docs.py, which audits a genuinely unconfigured repository through the production seam and asserts the payload carries the presentation question with all three options, then holds both instruction surfaces to naming the key — checked at the seam rather than against the vocabulary, since the vocabulary was right the whole time.

D26 — Closed: an unconfigured repository is asked, not audited

Decided 2026-08-21, against a proposal of three ways to disclose a provisional grade: an unconfigured run asks the questions first.

The behaviour that proposal would have decorated: when a host could not be elicited, the audit ran anyway on built-in defaults and filed its questions beside a finished report. Demonstrated on an unconfigured scratch repository — analyzers_requested: False, the pool off, a complete graded report, and the entire disclosure a single table row reading | Estimate source | Built-in detectors (fallback tier) |. The rendered report contained zero occurrences of "setup", "unconfigured", "first run", or "not configured". Meanwhile the run_pool question's own recommended answer is yes — so the number a first-time user saw was computed the opposite way from the one the product recommends, and nothing said so. That audit produced exactly that: a 3.9/C that the operator's own agent correctly called "not the product's answer".

D25 made the questions askable. It did not stop the report being produced before anyone asked them, which is the part that matters: a grade nobody can act on is worse than no grade, and an agent handed a finished report has no reason to go looking for the questions.

Setup is now a precondition. A repository with neither a repository config nor a user tier returns audit_ran: false, the question set, and a setup_instruction — no report, no score, nothing to mistake for an answer.

Amended 2026-08-22 (extended by D27). As shipped, this entry closed on "answering yields the real audit on the next call", and that clause is struck: it was the half of the ruling this entry got wrong. Answering configures the repository; the next call returns the run-or-reconfigure choice and the user says when to run. D26's own subject — that an unconfigured repository is never audited — stands unchanged.

Elicitation is unaffected: a host that can be elicited is asked before the audit and never reaches this path. An explicit config_path also passes through, so automation is not gated on an interactive answer.

An audit twice flagged a third test cited here, test_the_handed_back_questions_are_instructed_and_carry_every_format, which proves D25's claim about instruction surfaces rather than this entry's. Dropped rather than defended: a closing citation is the list of tests that fail if this defect returns, and padding it with adjacent ones is how a citation stops meaning anything. D31 records that no check can catch this — deciding what a test proves is a reviewer's job — which makes it the reviewer's job to keep the list honest.

Closing tests: test_setup_is_a_precondition_and_answering_it_yields_the_real_report in tests/test_setup_precondition.py asserts the loop whole — questions, then the choice, then a report honouring the chosen html presentation — because refusing to audit is only correct if answering unblocks it. test_declined_or_unsupported_elicitation_returns_questions_not_an_audit in the same file pins the degradation path.

D27 — Closed: configuring is not running, and setup is reachable on every run

Decided 2026-08-21, after D26 shipped half of it: the questions exist to set up the repository's configuration, so no audit runs automatically and none runs until they are answered; then the user is asked whether to run. A second run already has a config and is not asked the same questions again, but is still offered the choice to go back into configuration or to run the report.

D26 stopped the audit on an unconfigured repository. It then let answering the questions start one — so the user was asked how to configure the agent and, by answering, unknowingly launched a scan. Two decisions had been welded into one. It also had no way back into setup once a config existed: changing an answer meant deleting maintainability-agent.json.

Both are now the action argument, and its default differs by door on purpose. Unset never audits. An unconfigured repository returns setup_needed; a configured one returns choice_needed — run, or reconfigure — on that run and every later one. action="run" audits. action="reconfigure" reopens the setup questions for a repository that already has answers. Every non-audit reply carries audit_ran: false and no score. The MCP tool passes unset because a person is on the other end and has not been asked; the plain audit_repository function defaults to "run" for the CLI, the report resource, and scripted callers, which have already decided.

Elicitation follows the same rule: a host that can be elicited is asked the setup questions, the answers are written, and the call returns the run-or-reconfigure choice rather than a report. The test that used to be named test_one_native_elicitation_applies_answers_to_that_same_audit asserted precisely the behaviour being removed, and now asserts its replacement. One consequence worth stating: two calls produce one history record, because configuring is not scanning.

Closing tests: test_setup_is_a_precondition_and_answering_it_yields_the_real_report in tests/test_setup_precondition.py walks the whole flow — questions, choice, reconfigure, run — and test_one_native_elicitation_configures_and_then_asks_before_auditing in tests/test_first_run_elicitation.py holds the elicitation path to it.

D28 — Closed: the first-run help misdescribes the economics questions

Found by audit on 2026-08-22, auditing the docs against the code. docs/help/first-run.md presents economics as a single bullet — "economic context: skip, or low/base/high loaded labor rates". The form has four economics fields: the economics include/skip choice and three labor bounds, and the bounds are in the elicitation schema unconditionally, so a person answering "skip" is still shown all three. A reader of the help page cannot predict what they will see.

Assigned as a documentation fix. Release-blocking under the standing rule that a release ships only from an empty known-defect ledger.

Fixed. The page now lists all nine fields with their defaults and says the labor bounds appear whatever the economics answer is. Writing the falsifier turned up a second omission on the same page: it named chat as the presentation default without naming markdown or html — the invisibility that D22 and D25 are about, on the page a first-time reader is sent to. Corrected in the same pass.

Closing test: test_the_first_run_help_describes_the_form_a_person_actually_sees in tests/test_written_record.py, which reads the fields and their defaults from setup_questions rather than restating them, so a question added to the form has to reach the page before it ships.

D29 — Closed: ADR 011 states a status that stopped being true

Found by audit on 2026-08-22. docs/adr-011-three-report-presentations.md, Decision item 4, ends "that free-text ask remains open under D3". D3 is closed. The decision text itself is history and must not be rewritten — an ADR records what was decided when it was decided — but a status claim inside one goes stale and misleads a reader who takes the ADR as current.

The remedy is a dated amendment stamp under that item, in the form the register now uses for its own superseded clauses, not an edit to the decision.

Fixed with a dated amendment stamp beneath the decision item, leaving the decision text as the history it is.

Closing test: test_no_document_says_a_register_entry_is_open_that_the_register_closed in tests/test_written_record.py. It blocks the class rather than the instance: any document under docs/ claiming a register entry is open, while the register records it closed, fails until the claim is corrected or stamped — and a stamp directly beneath the claim counts, which is the form an ADR has to take.

D30 — Closed: the setup gate is every chat door, not one call site

Found by audit on 2026-08-22, auditing D21–D27. D26 made setup a precondition at audit_repository, and an audit of the other doors found the gate was one door wide.

The High: maintainability://report/{root} reaches build_report directly and never passes through the gate, so it still served the fallback-tier report for an unconfigured repository — the exact artefact D26 exists to prevent, on the same chat surface. A resource has no elicitation seam and cannot ask, so it refuses and names the door that can.

Three more from the same pass. A completed audit still carried setup_needed, because _finish_result re-attached the questions whenever the repository was pending: audit_ran: true beside a demand to configure, D26's shape surviving on the one path that bypasses its gate. An empty {} counted as configured, because the check was is_file() and a file is not an answer; a file that parses to nothing is now the same state as no file, and one that does not parse refuses by name instead of surfacing a JSONDecodeError from deeper in.

And the audit corrected this register. D27 claimed the action="run" default was how the CLI and the report resource skip the gate. Neither calls that function at all, and the precondition outranks the action regardless — an unconfigured repository returns questions however emphatically it is told to run. The docstring said otherwise for a day.

The shape of the fix matters more than any one hole: the falsifiers enumerate the doors rather than asserting the rule at a call site, because a precondition proven at one seam is a precondition on that seam.

Amended 2026-08-22 (D32). The title said "every door" and a second audit round measured it: the CLI, build_report and --backfill still score an unconfigured tree. That is the automation/CI door behaving as designed — a caller that has already decided, with no person to ask — but "every door" claimed more than the fix delivers, so the title now says chat door. The distinction is the product's, not an excuse: chat asks, automation assumes.

Closing tests: test_the_report_resource_refuses_an_unconfigured_repository, test_an_explicit_config_path_audits_without_carrying_setup_questions, test_a_config_file_with_no_answers_in_it_is_not_configured, test_an_unreadable_repository_config_refuses_at_the_setup_check, and test_run_never_overrides_the_setup_precondition in tests/test_setup_gate_completeness.py.

D31 — Closed: a closing citation names a findable test, not merely a real one

Found by audit on 2026-08-22, attacking the citation check added the same day. That check resolved every name under a Closing test marker against the suite, which stopped entries closing on deleted tests. It did not check the entry named the right file: the module stem in the cited path counted as its own hit, so an entry could send a reader to tests/test_first_run_elicitation.py for a function living in tests/test_setup_precondition.py and pass. Three entries were doing exactly that — D2, D26 and D27 — because the split that created test_setup_precondition.py moved the tests and left the citations behind. A falsifier a reader cannot find is not a falsifier.

The collector also missed async def test_.... No async test exists in this tree, so nothing was slipping through; the hole was real and is closed.

The audit's third defeat stands and is recorded rather than fixed: the check cannot read assertions, so an entry may cite a live test that proves something other than its defect. Blocking that mechanically would mean deciding what a test asserts, which is the reviewer's job. It is named here so the limit is known rather than assumed away.

Closing test: test_every_closing_citation_names_a_test_that_exists in tests/test_written_record.py.

D32 — Closed: the refusal reaches the reader, on every door

Found by audit on 2026-08-22, round 2, attacking round 1's fixes. Three of the four findings are defects in D30 and D31; the fourth is a claim this register made that the wire disproved.

The refusal did not survive the protocol. D30 said the report resource "refuses and names the door that can ask". Read through a real client, SetupRequired arrived as a bare -32603 carrying "Error creating resource from template …", and the sentence naming audit_repository survived only as __cause__ on the server side, where no user looks. In-process the refusal looked perfect, which is exactly why the test that proved it was in-process. It is now raised as the SDK's own ResourceError at the boundary, and asserted through a client rather than an exception handler.

One repository state, two experiences. A truncated config made the MCP tool and resource refuse by name while the CLI let a raw JSONDecodeError traceback out — on the door people run unattended, where a traceback is the least useful outcome available. The CLI now refuses in its own idiom, naming the file to repair.

A file name is not a falsifier. D31's check required cited names to exist and to live in the cited file, and an audit closed an entry with nothing but a path on the closing line: no name to resolve, so nothing to object to, so it passed. "Which test fails if this defect returns?" must have an answer, and now must have one written down.

And the title overclaimed. D30 said "every door"; the CLI, build_report and --backfill still score an unconfigured tree. That is the automation door behaving as designed — a caller that has already decided, with nobody to ask — but the entry claimed more than the fix delivered. Retitled to chat door and stamped, rather than left to read as a promise the code does not keep.

Two of the audit's attacks are recorded as accepted limits rather than fixed: a citation naming a real test that proves something other than its entry's defect (D31 already says no check can read assertions), and a test living in conftest.py, which the collector does not scan because no closing citation names it.

Closing tests: test_the_resource_refusal_survives_the_protocol and test_the_cli_refuses_an_unreadable_config_in_its_own_idiom in tests/test_setup_gate_completeness.py; the file-only citation is refused by test_every_closing_citation_names_a_test_that_exists in tests/test_written_record.py.

D33 — Closed: the fixes for D32, audited

Audit round 3, on 2026-08-23, attacking round 2's fixes. Three findings, all in code written the day before, plus one this check turned up on its own.

"Name a test" was satisfied by naming a file. D32 added an assertion that a closing citation must contain a test_ token. It passed the very attack it was written against, because the token it found was the filename inside the path: tests/test_x.py reads as "names test_x". Paths are stripped before looking now. Tightening it immediately caught two entries closing on a bare suite — D13 and D15, both predating the check and both invisible to every earlier version of it. Their falsifiers are named individually.

Only one refusal reached the reader. D32 wrapped SetupRequired so its message crossed the wire, and stopped there. A root outside the allow-list still arrived as "Internal server error", discarding the --allow-root sentence at the one layer that knows it — and that refusal is raised in the SDK's security callback, before the resource function the wrapper lives in. Both layers translate now, and the falsifier walks every refusal the resource can raise rather than the one an audit happened to name.

Two parsers, one file. setup_pending kept its own json.loads beside load_config's, so a JSON array made the MCP tool ask the setup questions while the CLI refused the file. One state, two answers — which is the defect D32 set out to remove and left standing one call away. They share a parser now.

Also corrected: load_catalog's missing-file message told the reader to rebuild from tools/build_catalog.py. Whoever sees that message on an installed copy has no such repository, and their actual problem is an incomplete install — which is D23 exactly. It now says which situation they are in.

Closing tests: test_every_closing_citation_names_a_test_that_exists in tests/test_written_record.py; test_every_resource_refusal_reaches_the_client_with_its_remedy and test_an_unreadable_repository_config_refuses_at_the_setup_check in tests/test_setup_gate_completeness.py.

D34 — Closed: config, history and baseline writes cannot be redirected (High)

Found by audit, 2026-08-23, security pass. D18 closed this class for the skill installer — check the path, then open the name — and paid for descriptor binding, O_NOFOLLOW, and staging plus os.replace, because a hardlink defeats O_NOFOLLOW outright. None of that reached the three writes a normal audit performs.

Reproduced here, independently of the report:

Every write into an audited tree now goes through _safe_write, which refuses a symlink at the name before resolving it — asking the resolved path is useless, since it is always the target — refuses a symlinked directory anywhere between the root and the file, and stages into a fresh file that it renames over the name. That last property is the only one that defeats a hardlink: the existing inode is never opened, so an outside file keeps its contents and merely stops being this name. History gave up append mode to get it, which costs a read of a few kilobytes.

Baseline splits into two questions, because they have different answers. Redirection is refused by the writer for every caller. Not overwriting source is refused where the path arrives from a model — _baseline_workflow — and a CLI caller keeping baselines outside the tree is left alone, which was always allowed.

Closing tests: test_setup_refuses_to_write_config_through_a_symlink, test_setup_refuses_a_symlink_pointing_back_inside_the_repository, test_history_append_cannot_reach_a_hardlinked_inode, test_a_first_scan_still_records_normally, test_a_baseline_may_not_overwrite_something_that_is_not_one and test_a_baseline_may_replace_a_baseline in tests/test_write_boundary.py.

D35 — Closed: the audited tree cannot enable tool acquisition (High)

Found by audit, 2026-08-23. product-intent.md P1 is explicit that acquisition is opt-in and that a user enables analyzers.acquire_tools. load_config is equally explicit that a repository always beats a person. _analysis.analyze then calls set_tool_acquisition(bool(settings.get("acquire_tools"))), so a pull request that adds four words to a config file causes the host to run npx --yes <tool> — unpinned, honouring the tree's own .npmrc.

Two aggravations. The published maintainability-agent.schema.json declares analyzers with additionalProperties: false and does not list acquire_tools at all, so the contract says the key is illegal while the runtime honours it — the schema is never loaded. And _ACQUIRE_TOOLS is a process-wide global, so two overlapping audits on a long-lived MCP can flip it under each other.

License policy already gets this right: deny always wins, and no repository can override it. Acquisition is the same kind of decision and did not.

acquisition_permitted() reads the XDG user tier alone and never the merged config, so a repository stating the key is ignored — a preference the tool declines to act on rather than an attack worth failing a scan over, since the environment work order already names every missing tool and how to install it. The key is now declared in the schema too: analyzers is additionalProperties: false and did not list it, so the published contract called it illegal while the runtime obeyed, and nothing caught the contradiction because the schema is never loaded.

test_analyze_honours_the_opt_in used to set config["analyzers"] and watch the switch flip — it asserted the inversion. It reads the user tier now.

Closing tests: test_no_configuration_means_no_acquisition, test_the_user_tier_can_enable_acquisition, test_a_repository_cannot_enable_acquisition, test_a_repository_cannot_revoke_a_users_choice and test_the_schema_declares_the_key_the_runtime_reads in tests/test_acquisition_trust.py; plus test_analyze_honours_the_opt_in in tests/test_network_disclosure.py.

D36 — Closed: the agent reads inside the grant, wherever the path came from (High)

Found by audit, 2026-08-23. D20 bounded paths.history with repository_path after an audit reproduced an escape. The same class survives in two fields that were never given the same treatment.

iter_files and expand_files now check where a path lands, and class_dirs goes through repository_path like paths.history did after D20. Symlinks that resolve inside the root stay allowed: they are ordinary in real trees, and refusing them would quietly shrink the population a score is computed over.

One thing the falsifier deliberately does not assert: that the name never appears anywhere in the report. git_status_short lists the symlink as untracked, which is git describing the working tree rather than this agent reading past its grant.

Closing tests: test_a_symlink_out_of_the_tree_is_never_scanned, test_a_symlink_that_stays_inside_the_tree_is_still_scanned, test_analyzer_argv_never_names_a_file_outside_the_tree and test_class_dirs_from_repository_config_cannot_leave_the_tree in tests/test_read_boundary.py.

D37 — Closed: git argv is validated, bounded, and its failures are not data (Medium)

Found by audit, 2026-08-23. validate_revspec refuses leading-dash arguments, and the MCP tool calls it. The CLI's --changed-only and --backfill do not, and neither changed_paths nor _backfill._git places -- before the revspec: changed_paths(repo, "--output=<path>") creates that file. Option injection, not shell injection — there is no shell=True anywhere in src/.

Beside it, and arguably worse for the product's own claims: run_git swallows every failure as except Exception: return "", so a failed shallow-clone check reads as "not shallow" and a failed git log becomes files_changed: 0. history.py states that "no history" and "no changes" must never be confused, and this is the code path that confuses them. Neither git spawner sets a timeout, and an inherited GIT_DIR overrides both cwd and -C.

Closed 2026-08-25. Four faults in one entry, and the fix for each is placed where a fifth caller cannot miss it.

The revspec. One definition now, git_tools.validate_revspec, lifted from the MCP door rather than written a second time — the first draft of a fresh pattern admitted -rf, because putting - in a character class allows it in the first position too. changed_paths and commits_in_range validate before git sees anything, so the CLI door inherits the guard rather than being told to remember it, and both append --. Validation rather than --end-of-options, which needs git 2.24 and would make the guarantee depend on the host's git.

Failure is not emptiness. run_git raised nothing and answered every error with "", so a failed git log reached _commits as no commits and was published as files_changed: 0 — the precise confusion has_history was written to prevent, created by the spawner beneath it. run_git now raises. The two calls where a non-zero exit is the answer — "is this a repository at all" — use probe_git, which says so at the call site, and the report's own git metadata probes too because a directory that is not a repository is a supported audit target. Making the strict case the default is the point: tolerating failure is now something a reader can see.

Bounded and bound. Both spawners pass a timeout, and both scrub GIT_DIR and its seven siblings, which outrank cwd and -C — an inherited value silently redirected every command here, including the worktree writes in _backfill.

The last two are held by a sweep over the package rather than by assertions about the two spawners that exist today. That is deliberate: this defect class was closed at the MCP door and left open at the CLI, which is the mistake the register keeps recording.

Audited twice independently, and three residues closed in the same entry. Both rounds on 0bc2b57 found the same thing from different directions: the fix had been applied to the sites this entry named and not to the class.

And the sweep was syntactic, not structural. It matched only subprocess.* calls whose first argument was a list literal beginning with "git"; a spawn built from a variable, a tuple, a concatenation or a module constant evaded it while the test stayed green and this entry claimed a new git call would fail there. Deciding which spawns are git is undecidable, so it stopped trying: every subprocess spawn in the package is now classified, and the analyzer child is named in ENV_EXEMPT with its reason rather than silently unchecked.

Closing tests: test_an_option_shaped_revspec_never_reaches_git, test_a_failed_git_command_is_not_an_empty_answer, test_a_repository_is_read_through_its_own_path_not_an_inherited_one, test_every_subprocess_spawn_is_bounded_and_classified, test_an_unborn_head_reports_absence_not_quiet_history, test_a_shallow_clone_reports_absence, test_an_unanswerable_shallow_check_withholds_rather_than_claims and test_a_rename_is_read_from_git_and_a_failure_is_not_no_renames in tests/test_git_argv.py.

D38 — Closed: a standing grant authorizes what the question named (Medium)

Found by audit, 2026-08-23. persist_root_grant stores the resolved path as a string; allowed_roots() resolves it again at process start. Elicitation refuses a symlink retarget in-process, and a restart does not: rename the granted directory, leave a symlink at the old name pointing somewhere sensitive, and the allow-list follows it with no new consent.

Closed 2026-08-25, reproduced first. Granting project, renaming it away and leaving a symlink at the old name put secrets in the allow-list on the next start, with no question asked.

The in-process seam was already right, which is what makes this a missed sibling rather than a new idea: _RootLedger.consume_ask surrenders the path the user was actually shown, so a link retargeted during the elicitation round-trip cannot swap the consented directory (the TOCTOU found on 6b2fb76). A restart went around it, because the allow-list was rebuilt from strings and re-resolved. Same rule, applied where the strings are read: a persisted grant is honoured only while it still resolves to itself, which a path that has acquired a symlink no longer does.

Fails closed, and a dropped grant is not an error — the user is asked again the next time that root is used, which is the point. A granted directory that was simply deleted keeps its entry and audits nothing, because there is no directory there to audit.

Reopened and re-closed 2026-08-26. The predicate that closed this was the inverse defect. It honoured a stored grant only when the path contained no symlink in any component — which on macOS is the ordinary spelling of /tmp, /var and every tempfile directory. The audit reproduced it: a grant recorded as /tmp/work was dropped on every start, so someone who said "always" was asked again forever.

The closing test could not see it. It called tmp_path.resolve() before storing, so it only ever exercised a path that was already canonical.

Grants persist resolved now. A canonical entry is honoured only while it still resolves to itself, which is the original guard; an entry that is not canonical — hand-written, or written before this change — is honoured unless the granted path is itself a link, which is the swap that was demonstrated. A component above it being replaced is a residual the weaker rule does not catch, and is why new grants are stored resolved.

And the falsifier for the reopen failed CI on its first run. It asserted that /tmp is a symlink, which is true on macOS and false on Linux, so it hard-failed on the platform it was meant to protect instead of skipping there. Every local full-suite run had been green, because every local run was on one platform: a platform fact asserted rather than constructed is invisible to a single-machine regression check, and this project's own discipline of diffing the whole suite against a baseline cannot see it either.

The test builds its own symlinked parent now — a real directory, a link to it, and a grant underneath the link — so it exercises the case on every platform rather than skipping where the platform does not volunteer one.

Reopened and re-closed a second time, 2026-08-26. The rule above — honour a non-canonical entry unless the granted path is itself a link — names its own residual in the paragraph that states it: "a component above it being replaced is a residual the weaker rule does not catch." The audit walked through exactly that. Retarget the parent of a stored grant, one directory above the leaf the rule checked, and the allow-list follows it on the next start.

There is no third rule. A bare path with no record of what it resolved to when it was granted cannot be defended: nothing to compare against means nothing to detect, and each attempt to defend it moved the hole one directory up rather than closing it.

So: canonical or refused. The product persists resolved paths, which are checkable, and a hand-written entry that is not canonical is refused. Refused aloud — server_info now carries refused_root_grants, each with the reason and the canonical spelling to write instead, because dropping grants in silence is what made the first version of this rule invisible for two days.

Closing tests: test_a_standing_grant_does_not_follow_a_renamed_directory, test_a_non_canonical_grant_is_refused_and_said_so and test_a_grant_the_product_made_is_canonical_and_survives in tests/test_grant_only_user_tier.py.

D39 — Closed: the audit takes no configuration from the tree it audits (Medium)

Found by audit, 2026-08-23, filed as a disclosure defect rather than a fix. eslint runs with no --no-eslintrc, and has_config requires a project config before running, so an audit of an allowed root executes eslint.config.js from the tree under audit; mypy runs without --no-plugins. Children inherit the host environment.

Child sandboxing is refused as a design direction and this entry does not reopen it. What it recorded originally is that P1 discloses "children are not network-sandboxed" and does not disclose "we execute configuration code from the tree under audit".

Reclassified 2026-08-25 from accepted residual to defect. It was filed as a disclosure gap because the alternative looked like a sandbox, and the entry offered "disclosure, then optionally the flags" as its closing test. Decision 9 removes the option: the agent does not execute the audited tree's code, and configuration is code. SECURITY.md already said as much, so the promise was never the thing that was wrong — this is the code drifting from a published claim, which is a defect by this project's own closure rules and not something disclosure can settle.

Scope shrank with Decision 10. eslint is the analyzer that requires the tree's flat config, and JavaScript is not a v1.0 language, so it leaves the default pool rather than gaining a flag. What stays in scope is Python: mypy and pylint may load configured plugins and must run with that disabled. The fix is therefore two narrow changes plus a falsifier, not the sandbox this entry was afraid of.

Two thirds closed 2026-08-25; the entry stays Open for the third.

The environment. _runner spawned every analyzer with the inherited environment, so PYTHONPATH, NODE_PATH, NODE_OPTIONS, PYTHONSTARTUP and the LD_/DYLD_ pair could choose what the tool loads. analyzer_env() removes them and keeps PATH, because the tool still has to be found. Not a sandbox and not claimed as one — the narrower guarantee Decision 9 actually makes.

eslint. It cannot run without the tree's flat config, and a flat config is a JavaScript program. EslintAdapter now declares executes_audited_configuration, and selection refuses any adapter that does — a property rather than a slug check, so the next tool needing the tree's own config is refused without anyone remembering this entry. Refusal is its own coverage outcome: reporting it as no-adapter told the reader to write an adapter that already exists. The work order also stopped naming eslint as an install that would close a JavaScript gap; following that remedy would have closed nothing.

mypy and pylint. Both run through DECLARED in _generic, both read the tree's configuration, and pylint's init-hook= executes arbitrary Python at startup. They are spawned with --config-file and --rcfile pointed at os.devnull, which stops the search that honours plugins =, load-plugins= and init-hook=.

This entry stalled for a while on the wrong question. Neither tool is installed on this machine, so the flags could not be demonstrated here, and the proposal was to add them as dev dependencies to make CI prove them. The premise was rejected: analyzers are runtime prerequisites the user supplies, never dependencies of this package — analyzer-pool.md says so, and making one a dependency would contradict the pool's whole design. The absence was a machine that had changed, not an architecture.

The pattern the pool already discloses is the answer. Checkstyle and SpotBugs sit in their tiers with live spawns that skip wherever nobody supplied the binary, including CI. The isolation flags follow it: a structural sweep that always runs and forces every declared tool to be isolated or to state it reads no tree configuration, and live tests that plant a hostile pylintrc and mypy.ini and assert the tree's code never runs — skipped where the tool is absent. On a machine with pylint and mypy installed, that is demonstration; here it is documentation plus an asserted argv, and the disclosure says which.

Closing tests: test_every_declared_tool_is_classified_for_tree_configuration, test_a_declared_tool_is_invoked_with_its_configuration_isolated, test_no_selectable_adapter_needs_the_audited_trees_configuration, test_pylint_does_not_run_the_trees_init_hook and test_mypy_does_not_load_a_plugin_from_the_tree in tests/test_analyzer_config_isolation.py; test_the_analyzer_child_cannot_be_told_what_to_import in tests/test_git_argv.py. The last two of the first group skip where the binary is absent, which analyzer-pool.md discloses.

D40 — Closed: a repository's regex cannot hang the host (High)

Found by audit, 2026-08-23, proven. risk_patterns are compiled from repository configuration and applied to every source line. The schema bounds neither their complexity nor their size. Pattern (a+)+$ against thirty-one a characters and a ! did not finish in two seconds.

The security policy names crafted-configuration denial of service as in scope, so this was a defect against a stated promise rather than a new requirement.

Patterns are measured rather than inspected. Recognising a dangerous regex syntactically means a blocklist that is both leaky and prone to refusing honest patterns; running it against a probe asks the only question that matters. The probe has to be short enough to return — timing something that never finishes measures nothing — so there are two: twenty characters, where (a+)+$ costs ~42 ms against ~0.01 ms for a real pattern, and twenty-four, which is where the slower (a|aa)+$ family finally shows itself at ~9.7 ms. Only patterns that survive the first reach the second, so a bomb costs the sum of two small budgets rather than the near-second its own longer search takes.

A refused pattern is skipped, not fatal: a repository's own lint config should not be able to fail somebody else's audit, and the other rules keep running.

Closing tests: test_a_backtracking_pattern_is_refused across four shapes of catastrophic backtracking, test_an_uncompilable_pattern_is_refused_rather_than_raised, test_every_shipped_pattern_survives_the_budget — the guard must not quietly disarm the product's own detectors — test_a_hostile_pattern_does_not_stall_a_scan with the clock running through the real scanning path, and test_a_refused_pattern_does_not_silence_the_others, all in tests/test_pattern_budget.py.

D41 — Closed: every action is pinned to a commit (High)

Found by audit, 2026-08-23, proven. Every workflow action is referenced by tag or branch. release.yml hands OIDC publication authority — id-token: write — to pypa/gh-action-pypi-publish@release/v1, a moved-branch reference rather than an immutable commit. Checkout, setup-python, uploads, the CodeQL upload, GitHub Script and Sonar are the same.

Whoever controls those references at run time runs in the job that can publish this package.

Twenty references across three workflows and the composite action are now commit SHAs, each with its version as a trailing comment so a reader can still tell what is pinned. This repository's own action stays on @main on purpose: pinning it would mean CI could never exercise the commit under review.

Closing tests: test_every_third_party_action_is_pinned_to_a_commit, parametrized over every workflow file, and test_the_publishing_job_is_pinned_hardest_of_all in tests/test_workflow_supply_chain.py. The second is deliberately redundant with the first: a general rule is easy to relax for one awkward case, and the publish job is the case nobody should relax it for.

D42 — Closed: the package claims a Python it can run on (Medium)

Found by audit, 2026-08-23. requires-python = ">=3.10", and _discovery.py, _pillars.py and _runner.py import enum.StrEnum, which is 3.11. Pip installed happily on 3.10 and the import then failed. CI tests 3.12 alone, and the composite action pins 3.11, so nothing in the pipeline stood where the metadata said a user could stand — which is why no test caught it and an audit had to. Floor raised to 3.11, and the supported versions declared as classifiers.

This entry first recorded that no honest falsifier existed here, on the grounds that any such test would restate a constant. The register's own citation lint disagreed — it refuses a closed entry that names no test — and it was right. The check does not restate the floor; it ties the floor to the language features actually imported, which is the relationship that broke.

Its first version matched the bare word anywhere and flagged _economics.py for the English "override" in a docstring. A check that cries wolf is a check somebody turns off, so it reads import and decorator lines only.

Closing test: test_the_declared_python_floor_supports_the_features_in_use in tests/test_policy_claims.py, verified by lowering the floor back to 3.10 and watching it name _discovery.py and StrEnum. A CI matrix entry on the floor version is still worth having and stays recorded as follow-up in docs/security-queue.md.

D43 — Closed: composite-action inputs are data, not source (Medium)

Found by audit, 2026-08-23, proven. action.yml embedded ${{ inputs.* }} directly inside a run: script. GitHub substitutes those before bash parses the script, so an input was never an argument — it was source code, and a path containing a shell metacharacter was enough to break a run by accident.

Inputs arrive through env: now and are appended to the argument array quoted, which keeps each one a single word however it is spelled.

Closing tests: test_the_composite_action_never_interpolates_inputs_into_bash and test_the_composite_action_still_passes_every_input_through in tests/test_workflow_supply_chain.py. The second exists because moving inputs to env: is exactly the kind of edit that silently drops one, and a lost --changed-only would look like a passing test while auditing the whole repository on every pull request.

Both are parsed by hand rather than with PyYAML: this repository keeps its test extra thin and test_declared_imports refuses a dev-only parser in tests. PyYAML was added anyway, that lint caught it, and the parser was rewritten.

D44 — Closed: the annotations are derived from the behaviour (Medium)

Found by audit, 2026-08-23. The audit tool declares itself non-destructive and closed-world, and tests/test_mcp_server.py locks both values. Neither survives optional network acquisition, unsandboxed analyzer networking, executable repository analyzer configuration, or the config, history and baseline writes.

Test-backed misinformation is worse than an untested claim: the suite is the reason nobody re-examined it.

~~Blocked on the trust decision~~ — unblocked by Decision 9, which answered it by drawing the line at executing code instead.

Closed 2026-08-25. audit_repository now advertises destructive_hint=True and open_world_hint=True. It replaces an existing configuration when setup reruns and an existing baseline when asked to write one — non-additive updates to files in the user's repository, which is what the destructive hint means. That only the agent's own five artifacts are ever touched (D34) is a real guarantee and a different claim from "additive". And it is not closed-world: analyzers.acquire_tools can fetch a missing Node tool through npx --yes, and analyzers are ordinary local children this package does not sandbox, which P1 discloses in as many words. get_agent_info was accurate and is unchanged.

Decision 9 removed exactly one of the four reasons this entry gave — the tree's own analyzer configuration no longer executes (D39). It did not make the tool closed-world, and the other three stood.

The lesson is the lock, not the values. Two tests asserted non-destructive and closed-world for every tool, and a third statement sat in ide-agent-integration.md. Changing the literals would have left the same defect available, so the falsifiers derive each hint from a fact stated elsewhere in the package: the hint must follow server_info's list of what this agent writes, and follow the existence of the acquisition setting. Changing the behaviour without changing the hint now fails.

Closing tests: test_a_tool_that_writes_is_not_advertised_as_read_only, test_replacing_a_file_a_user_owns_is_advertised_as_destructive, test_a_tool_that_can_reach_the_network_is_not_advertised_as_closed_world and test_the_read_only_tool_is_still_read_only in tests/test_annotations_match_behaviour.py.

D45 — Closed: the security policy supports the shipped release (Medium)

Found by audit, 2026-08-23. SECURITY.md stated that only 0.1.x receives security fixes, at version 0.9.1 — read literally, the shipped release was unsupported by its own policy.

Fixing the table is trivial; keeping it fixed is the problem, since nobody remembers a version number buried in prose. The falsifier reads config.VERSION, so the next release either updates the table or goes red.

The same pass corrected a worse sentence in the same file. Scope asserted that the agent "does not execute scanned code" while eslint is invoked in a mode that requires the audited repository's own configuration and then runs it. Whether repositories should be trusted is an open decision (D39, D44); asserting a property the code does not have is not, so Scope now describes what the code does and says the decision is pending.

Closing tests: test_the_security_policy_supports_the_shipped_release_line and test_the_security_policy_states_the_guarantee_the_code_keeps in tests/test_policy_claims.py. The second checks only what the document asserts, not what it recounts — a check that could not tell an assertion from its own correction would forbid explaining the fix.

D46 — Closed: an analyzer cannot decide how much work reading it is (Low)

Found by audit, 2026-08-23, inferred rather than demonstrated. _generic.py and _jvm_adapters.py parse analyzer XML with ElementTree.fromstring. The input is a child process this agent spawned, not an upload, so the realistic exposure is resource exhaustion from a hostile or PATH-hijacked analyzer rather than classic XXE.

Closed 2026-08-25, and it stopped being inferred. The hedge was right about XXE and wrong about the risk: external entity expansion and DTD retrieval are already safe in this interpreter, which is what "XXE" usually names. What ElementTree still does is expand internal entities — four levels of the standard shape take a 400-byte document to 30,000 characters here, and each further level multiplies by ten.

The obvious fix is to disable expat's entity handler, and CPython 3.11 does not expose the underlying parser to reach it. The narrower guard is better anyway: no analyzer this project runs emits a DTD, so a document declaring entities is not output this code should be reading. _xml.parse_analyzer_xml refuses it before a parser sees it, and refuses absurd length as well — the flood case a declaration check cannot catch, because a bomb is small by construction.

AnalyzerXmlRefused subclasses ElementTree.ParseError so that both call sites keep the handling they already had: unreadable analyzer output is a stated coverage gap, never a crash. Output this project declines to read is exactly that.

The sweep is the part that matters. ElementTree.fromstring now appears once in the package, inside the guard, and a test walks every module to keep it that way — a third parse site added tomorrow is how this would otherwise come back.

Closing tests: test_an_entity_bomb_is_refused_before_it_is_expanded, test_a_declared_doctype_is_refused_even_without_entities, test_absurdly_large_output_is_refused_rather_than_read, test_real_analyzer_output_still_parses, test_a_refusal_reads_as_unparseable_output_to_every_caller and test_no_parse_site_bypasses_the_guard in tests/test_analyzer_xml_bounds.py.

D47 — Closed: the call-first rule reaches the door hosts are actually handed

Found by inspection on 2026-08-24, while diagnosing a field report.

Opened on a wrong diagnosis, and corrected the same day. This entry was first written as the cause of that report: an operator starting a first-run test whose host opened by asking which maintainability-agent.json to use — committed in HEAD, deleted in the working tree — instead of calling the tool. It is not the cause. The host had the skill, and the skill it had was the pre-D21 copy from the installed v0.9.1 wheel, whose step 1 reads "Configuration check first: look for maintainability-agent.json...". The host followed its instructions exactly. D21's fix was never in the artifact that machine loads, because nothing has been released since the fix landed.

What is recorded below is a real and separately open gap, found while chasing that report and worth closing on its own terms. It is not what produced the screenshot, and the first draft of this entry claimed it was.

The gap is this. D21's falsifier read skills/maintainability-agent/SKILL.md and stopped there. The skill is opt-in; SERVER_INSTRUCTIONS is what every chat host receives on connect, and it said nothing about the order at all. The MCP prompt was worse than silent: it opened "First offer the presentation choice ... Then call audit_repository", which is both stale against D26 (presentation belongs after the setup and run/reconfigure asks) and an explicit licence to ask something, then call.

Both MCP surfaces now carry D21's rule in D21's words, and the prompt states the current two-ask contract instead of the old one. The falsifier holds all three instruction surfaces to it together, which is the check D22 had already established and that nobody applied backwards to D21.

The gap was closed on inspection, not on a reproduction. No field run is known to have failed because of it — the one that prompted the look failed on a stale artifact instead (see the correction above, and D49).

Closing test: test_every_chat_instruction_surface_calls_the_tool_before_inspecting_config in tests/test_chat_primary_docs.py. D21's own test is left as it stands: it holds the skill, which is the surface that did fail in the field — on a copy older than the fix.

D48 — Closed: a refusal is declared, not a crash

Found by CI on 2026-08-24, on an unchanged main. mcp floats on >=2,<3; 2.1.0 shipped that day, and three boundary tests that had passed at 06:31 failed at 23:54 on the same SHA.

It is not a regression in the SDK. 2.1.0 draws a line the product had never declared a side of. A failure raised as ToolError or ResourceError is one the server saw coming: its message reaches the caller and the server logs it at INFO without a traceback. Anything else is a crash — the caller gets Error executing tool <name>, or a resource's bare URI, and the traceback is kept server-side, so nothing internal leaks. That is a correct and well-documented distinction.

The path boundary is the most anticipated failure in this system. It was raised as a plain PathNotAllowed, which is a ValueError — so 2.1.0 classified it as a crash, correctly, and withheld the text. D10's requirement that a refusal teach --allow-root and MAINTAINABILITY_MCP_ALLOWED_ROOTS was deleted by a patch release of a dependency. The report resource lost SetupRequired's message the same way.

The security boundary itself never moved: the refusal still refused, is_error still true, the path still blocked. What was lost is the part that teaches. Before 2.1.0 the crash path happened to interpolate the message anyway, which is why this was invisible rather than absent.

Both MCP seams now declare their anticipated refusals while the plain functions keep raising the domain types. The dependency range is unchanged: pinning below 2.1.0 would preserve a misclassification and treat a correct SDK change as damage.

The anticipated set was wrong twice in one night, in opposite directions, which is why a reviewer eyeballing it is not the falsifier. It began as (ValueError, PathNotAllowed, SetupRequired). Bare ValueError swept in failures from below the transport — _jvm_adapters raises "unreadable checkstyle XML: <path>" — so internal paths would have reached callers as declared refusals. It was narrowed to (InvalidAuditArgument, PathNotAllowed, SetupRequired), which silently demoted two refusals raised on purpose for the caller to act on: StaleBaseline ("Regenerate with --write-baseline") and PolicyError ("unknown depth 'x'"). The set is now five named types. EvidenceValidationError stays out: on the tool path the report is built internally, so a failure in it is an internal bug.

The last correction of that tuple recrossed this repository's 500-line file gate (mcp_server.py went to 513). The PR that claimed CI green was red on both Verify and Audit. The module is back under the gate.

Two assertions that used to demand pytest.raises(PathNotAllowed) through read_resource now assert the refusal the client receives: declared as ResourceError, message names the boundary, domain type preserved as __cause__. That holds on 2.0.0 and 2.1.0 both.

What the tests prove, and what they do not. The two original seam tests exercise PathNotAllowed through the tool and the report resource, and prove a below-transport ValueError stays a crash (the latter skips on 2.0.0, where the SDK interpolates crash text itself). Three more now drive the rest of the tuple: StaleBaseline and the seam's own InvalidAuditArgument out of the audit tool, and SetupRequired out of the report resource's own handler — which the validator's earlier refusal had been shadowing, so every existing resource test caught the validator and none reached the handler beneath it.

What proves "declared" is not the same on the two seams, and this paragraph twice said it was. The first draft asserted only that the caller's message is not the SDK's generic crash string. On mcp 2.0.0 that is true of the tool whether or not the refusal was declared, because 2.0.0 interpolates crash text there — so the draft passed with StaleBaseline deleted from the tuple. The two tool tests were rewritten to call the coroutine _bind_audit_tool registers, skipping the SDK entirely: ToolError with the domain type as __cause__ is our own translation and holds on any 2.x.

The correction was then overapplied to the resource, and an audit of this entry caught that too. The resource test goes through read_resource, and undeclaring SetupRequired there produces a ResourceError whose __cause__ is still SetupRequired — the SDK wraps the escaping exception and chains it — so neither the class nor the cause separates a refusal from a crash. Two assertions do, on different versions: on 2.1.0 the wrapper is the narrower UnexpectedResourceError, a ResourceError subclass, and the test excludes it; on 2.0.0 no such class exists and the wrapper's message replaces the refusal's own, so text is the discriminator. Text is load-bearing on that seam, which is the opposite of what this paragraph claimed while the two tool tests were being praised for avoiding it.

Which text, and a third correction. The audit of 790a47e as landed found that the 2.0.0 check was looking for the wrong string. It searched for audit_repository — D30's requirement that a refusal name the door that can ask — but the 2.0.0 wrapper interpolates the URI, the URI carries the repository path, and a repository directory named audit_repository puts that substring into a crash message. Reproduced against this tree: a crash satisfied the assertion whose job is catching crashes. Narrow, and the fixture did not hit it, so nothing was passing falsely in the suite.

Closed by making the case impossible to lose rather than by recording it: the fixture now builds its repository under a directory called audit_repository, and the discriminator is has not been set up, a fragment of D30's own sentence that a URI cannot contain. Weakening the assertion back to the door-name substring now fails the build. The door-naming check remains, separately, because it is D30's actual requirement — it was never a crash discriminator and is no longer asked to be one.

That is three rounds finding three defects in this entry's own falsifiers, each a claim that the test proved something slightly larger than it did. The lesson recorded here is not about mcp: a falsifier that shares a vocabulary with the thing it tests can be satisfied by the failure it exists to catch.

Each of the three was verified by deleting its type from the tuple and watching its own test fail.

PolicyError is deliberately not driven, and cannot be. Every site raising it is reachable only through _analysis.analyze(), which catches it and returns Analysis(error=...); a test would have to fake a call path the product does not have. Architecture invariant 12 now says that rather than implying the two types stand on the same evidence — an audit of this entry caught it claiming they did.

Membership — of those types and of any sixth added tomorrow — is test_every_named_exception_is_a_declared_refusal_or_excluded, which derives every named exception in the package and requires each one in the tuple or excluded with a stated reason. SkillDrift is the CLI-only exclusion; UnsupportedReportSchema rides EvidenceValidationError. The three seams must except the tuple by name, so a hand-written copy cannot hide a miss.

Closing tests: test_a_refusal_is_declared_rather_than_crashing_out_of_either_seam and test_a_failure_from_below_the_transport_stays_a_crash in tests/test_root_grants.py; 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 and test_an_unconfigured_repository_refuses_through_the_resource_seam in tests/test_anticipated_refusals.py.

D49 — Closed: the skill in the distribution is asserted, not assumed

Found on 2026-08-24 while diagnosing a field report. Two tests read _skill_data/SKILL.md and both read it out of the source checkout: test_every_delivery_surface_offers_all_three_presentations byte-pins the mirror against skills/maintainability-agent/SKILL.md, and test_skill_install reads the same source path. The payload a host actually loads — carried in the distribution and written out by --install-skill — was asserted by nothing.

That is D23's hole on a different payload. D23 closed by staging a real build and reading what came out, but only for _assets; the skill payload was left on the assumption that declaring it was the same as shipping it.

The staged distribution must now carry _skill_data/SKILL.md, be byte-identical to the reviewed skill, and contain D21's rule — the call first, and no "configuration check first" anywhere in it.

What this entry does not explain. It was opened while chasing a stale skill on an operator's machine, and it is not the cause of that. That copy was current for the release it came from; it is old because v0.9.1 predates D21's fix and nothing has shipped since.

Closing test: test_the_shipped_skill_is_the_current_one_and_carries_its_first_rule in tests/test_wheel_contents.py.

The falsifier is the two content assertions, and only those. Drifting the mirror from the reviewed skill fails the byte-pin; reintroducing "configuration check first" fails the rule check. Absence of the staged file fails the same read. A REQUIRED entry on the package-data glob was drafted alongside them and dropped: removing _skill_data/**/* from package-data and staging a build again leaves all six files in place — setuptools includes them by another route — so that assertion cannot be falsified through the declaration it appears to guard. A check that cannot fail is not a proof, and D15 was reopened once for exactly that confusion.

D50 — Closed: the economics ask stops at the question it gates (High)

Decided 2026-08-25, during UAT preparation: a user who declines economics is never asked the labor-rate questions.

_economics_questions returned the gate and all three labor rates in one flat elicitation model, and the gate's default is skip. So the default path walked a person through three money questions for something they had just declined. The function's own docstring called it "the declinable ADR 004 ask" while nothing about it was declinable.

Two stages now: six questions, then the three rates only for someone who answered include. setup_pending stays true until the rates arrive, so the second ask is the existing gate doing its job rather than a new mechanism.

An audit had examined this surface and filed it under no-finding, citing docs/help/first-run.md — which stated plainly that the labor fields remain visible after skip. The page was accurate. D28 had made it accurate, and accuracy about a bad form is what kept the form. An audit that checks code against documentation cannot catch a defect both of them share.

Closing tests: test_setup_questions_are_structured_choices_with_disclosed_defaults and test_apply_answers_persists_economics_and_format_to_both_tiers in tests/test_first_run_elicitation.py; test_the_first_run_help_describes_the_form_a_person_actually_sees in tests/test_written_record.py.

D51 — Closed: asking for the economic scenario is not the same as declining it (High)

Found while closing D50. _economics_block needs all three rates and returned None without them — the same value as declining — so a user who answered include in a round that carried no rates had no economic context written and was never asked again. The one thing they said yes to was the one thing silently discarded.

The gate answer is recorded as a request now, and economics_bounds_pending reads it to ask for the rates on the next call.

Closing test: test_apply_answers_persists_economics_and_format_to_both_tiers in tests/test_first_run_elicitation.py.

D52 — Closed: a labor rate is refused where it is answered (High)

Found by audit, 2026-08-25. Setup accepted labor_low=-1 and wrote both configuration tiers happily; the next action="run" raised a raw ValueError: loaded_engineering_cost_per_hour must satisfy 0 < low <= base <= high from the scoring path. Same for low > base. The person who typed the number was two calls and one document away from the message about it.

The rule is checked where the answer is given, and the refusal names the three values it got.

Closing test: test_apply_answers_persists_economics_and_format_to_both_tiers in tests/test_first_run_elicitation.py.

D53 — Closed: a configuration key of the wrong shape is refused, not crashed on (High)

Found by audit, 2026-08-25. _configured validated JSON syntax and an object root, then merged whatever it found. {"thresholds": "nope"} surfaced as a raw TypeError: string indices must be integers from inside scoring, and {"hard_gates": []} as an AttributeError on a list — two stack traces for one broken file, neither naming the file.

ConfigUnreadable already existed for exactly this reader and covered only unparseable bytes. It now covers a known key whose value is the wrong container, derived from DEFAULT_CONFIG so a key added there is checked the day it is added. Unknown keys stay permitted: this is a shape check, not a schema, and refusing what it does not recognise would break every config written against a newer version.

Closing test: test_a_known_key_of_the_wrong_shape_is_refused_by_name in tests/test_config_shape.py.

D54 — Closed: expected_files names files in the repository (Medium)

Found by audit, 2026-08-25. paths.history was bounded by D20 and this was not, so a repository config could say /etc/passwd or ../outside and the report would state whether that existed — a repository- controlled probe of the machine auditing it, answered in the output. Absolute entries and entries containing .. are refused by name.

Closing test: test_expected_files_cannot_leave_the_repository in tests/test_config_shape.py.

D55 — Closed: the documents stop offering a tool the product refuses (Medium)

Found by audit, 2026-08-25, and both halves are mine. analyzer-pool.md still listed eslint as a Node runtime need, as fetchable through npx, and as a verified moderate adapter — three offers to install a tool D39 had just made unrunnable. The D39 change corrected the prose two paragraphs above and left the tables.

maintainability-agent.schema.json also described prompt_when_interactive as "Reserved. Stored and not read." while _first_run reads it, so an IDE user consulting the schema was told a live switch does nothing.

Closing test: test_no_document_offers_an_analyzer_selection_refuses in tests/test_config_shape.py.

D56 — Closed: an empty history window is unknown, not perfect (High)

Found by audit, 2026-08-26. _history_rate_aspect returned 5.0 for files_changed == 0, commented "had history to read; nothing changed in the window". A repository whose only commit predates the twelve month window therefore scored full marks on every history aspect — with a filthy working tree the photograph could see and the window could not. Reproduced on a real repository: one commit dated 400 days ago, an untracked file with eighty nested conditionals, history rates 5.0.

Worse in the direction P3 names. Withholding the history object lowers the result (estimate 4.80, no verified grade). Supplying an empty window raises it to 5.00 and A+. Evidence that says nothing outscored evidence that was absent.

This is D37's collapse one layer up. There, a failed git log produced zeros and the zeros read as quiet; the fix made the spawner raise. Here the log succeeds and produces the same zeros honestly, and they mean the same thing: no denominator, therefore no rate. A shallow clone and an empty window are the same state, and the docstring above this function already said a shallow clone must not grade as clean or dirty.

Closing test: test_an_empty_window_is_unknown_rather_than_perfect in tests/test_history_window.py.

D57 — Closed: the documented languages and the parsed languages are one set (High)

Found by audit, 2026-08-26. docs/language-support.md and Decision 10 said v1.0 handles Python and Java. The scanner also read JS, TS, JSX and HTML, so a repository of 140 JavaScript files was reported with declarations_scanned=140, evidence_status: complete and a verified B, and the Markdown scored declaration size.

The first two attempts at this were both wrong and are recorded because the sequence is the lesson. Narrowing only the sentence left the contradiction the audit had already named. Narrowing the parser to {.py, .java} removed JavaScript dead-code detection, idiom divergence, near-duplicate pairing and ADR 003's TypeScript work — and The question of what option C is worth without detectors, linters or an adapter for those languages is what produced the check that should have come first: lizard, jscpd and multimetric are baseline-tier adapters that read JavaScript, and baseline in this project means installed, run and parsed. Only eslint is refused, and only for its config.

So the claim follows the capability. The declaration languages are Python, Java, JS, TS, JSX and HTML, the page says so, and the one-adapter-per-release cadence applies to what nothing here reads — Go, Rust, C, C# and Fortran, absent from the default extensions rather than scanned and unscored.

Closing tests: test_the_parsed_languages_are_exactly_the_documented_languages and test_every_scanned_source_suffix_can_be_read_by_something in tests/test_claimed_languages.py.

D58 — Closed: the generated standards pack teaches call-first (High)

Found by audit, 2026-08-26. --init-agent-standards writes AGENTS.md, CLAUDE.md and their siblings, and every one of them opened with "Start with a configuration check (maintainability-agent.json, then the user tier)" — the archaeology D21 exists to stop, shipped into the file an agent reads before anything else.

D47 closed that class by enumerating three surfaces: the MCP server instructions, the slash prompt, and the skill. This is the fourth, and D47's own write-up of D21 says what went wrong here: a falsifier that read one file and called the class closed.

Worse, the two closed entries contradicted each other. D17's closing test required the generated pack to contain "configuration" and "check", so honouring D21 there would have failed the suite. Both were green, and neither could see the other.

The pack now carries D21's wording verbatim, D47's sweep runs over four surfaces, and D17's assertion asks for the call instead of the check.

Closing test: test_every_chat_instruction_surface_calls_the_tool_before_inspecting_config in tests/test_chat_primary_docs.py.

D59 — Closed: the sweeps lint the class, not a name (High)

Found by audit, 2026-08-26, three of them together.

The isolation sweep covered two tools. It diffed DECLARED — pylint and mypy — while ADAPTERS holds fifteen, so ruff sat in the baseline pool, in the default selection, never asked the question the sweep exists to ask. Every adapter is now classified by what honouring its configuration would execute, which is the actual line Decision 9 draws: a ruff or flake8 config is TOML and INI, and a repository choosing which of its own lint rules apply is policy about its own code. The flag check also asserted only that --rcfile= was present, so --rcfile=.pylintrc would have passed; it asserts the value now.

The subprocess sweep matched subprocess.<call>. import subprocess as sp, from subprocess import run, subprocess.call and getoutput all evaded it, and timeout=None counted as bounded because presence was the test. It resolves aliases and from-imports now, and treats an explicit None as unbounded.

The XML sweep had the same shape, matching ElementTree.fromstring by name, so a from-import walked past it.

This is the third generation of these checks. The first git sweep matched only list literals beginning with "git"; that was replaced after an audit, and the replacement was narrow in a different way. What they have in common is that each was written to catch "a call added tomorrow" and each matched a spelling instead.

Closing tests: test_every_adapter_is_classified_for_what_its_configuration_executes and test_an_adapter_whose_config_executes_code_is_refused_not_isolated in tests/test_analyzer_config_isolation.py; test_every_subprocess_spawn_is_bounded_and_classified in tests/test_git_argv.py; test_no_parse_site_bypasses_the_guard in tests/test_analyzer_xml_bounds.py.

D60 — Closed: SECURITY.md states the guarantee the code keeps (Medium)

Found by audit, 2026-08-26. Decision 9 closed D39 and D44 on 2026-08-25 and SECURITY.md kept describing the defect for a further day: that the agent executes repository code, that mypy and pylint can load configured plugins, that children inherit the host environment, and that the question is open.

Wrong in both directions now. The file first denied executing scanned code while eslint was being invoked in a mode that requires the tree's configuration and then runs it (audit, 2026-08-23). It was corrected to assert the opposite. Then the code changed under it.

Both directions are recorded rather than quietly rewritten, and the test no longer forbids a sentence: it reads the policy against the adapters, and fails if the file and the code disagree either way. A test that asserts a phrase protects the phrase, not the property — which is the third time that shape has come up today.

Closing test: test_the_security_policy_states_the_guarantee_the_code_keeps in tests/test_policy_claims.py.

D61 — Closed: P1 names the fields that are not compared (Low)

Found by audit, 2026-08-26. P1 promised "same report out" and two runs on one tree differed by a millisecond, on an analyzer's wall-clock seconds. The determinism check had been stripping root, git_status_short and every seconds for as long as it had existed, so the promise was false of the JSON a consumer diffs and true only of the test's own projection.

The exceptions are legitimate — a duration is a fact about the run, not about the repository. Being undisclosed was not. P1 names them, and a field added to the strip list without reaching the page now fails.

Closing test: test_the_determinism_exceptions_are_exactly_the_disclosed_ones in tests/test_determinism.py.

D62 — Closed: the release plan is measured, not remembered (Low)

Found by audit, 2026-08-26. The plan's own warning is that a previous version of its table "survived fifty-five commits past the point it stopped being true". It then did it again: last tag 0.7.0 with v0.9.1 shipped, 14,122 lines against 20,071, 1,097 tests against 1,560. Re-measured, and the warning now records both occurrences.

The first draft of this entry closed with "Closing test: none", arguing that a measurement goes stale by existing. The register does not allow that, and it is right not to: the tag is exact and the counts are checkable within a stated tolerance, wide enough to survive ordinary work and narrow enough that another fifty-five commits cannot hide inside it.

Closing test: test_the_release_plan_table_is_measured_not_remembered in tests/test_release_plan.py.

D63 — Closed: the platform is claimed where it is demonstrated (Medium)

2026-08-26: what happens for Windows users? — asked about a test that had just failed CI for asserting /tmp is a symlink. The answer was larger than the test.

pyproject named no operating system at all. CI runs ubuntu-latest and nothing else. Seven test files create symlinks with no platform guard, and os.symlink needs Developer Mode on Windows, so the suite cannot reach the point of reporting whether the product works there. A case-insensitive filesystem would also meet D38's standing-grant check, which compares a stored path against its resolved form.

Meanwhile two documents implied Windows was supported: config-schema.md said exclude patterns are "normalized across Unix and Windows path separators", and ide-agent-integration.md names the ; separator. Normalizing separators is not the same claim as running on the platform, and a reader takes the friendlier reading.

Operating System :: POSIX is declared, both documents say Windows is untested, and README carries a platform section that states the evidence rather than a preference. Widening the claim means adding a windows-latest runner and guarding those seven fixtures; the tests below fail the moment either half moves without the other.

Fourth in two days. JS scored without being claimed, SECURITY.md denying what the code did, P1 promising more than it compared, and now a platform nobody had run. Every one was a claim resting on a check that only ran where the claim was true.

Closing tests: test_the_package_claims_the_platform_it_is_tested_on, test_ci_runs_only_platforms_the_package_claims and test_the_symlink_fixtures_are_still_unguarded in tests/test_platform_claim.py.

D64 — Closed: flake8 reads no configuration from the tree (Medium)

Found by audit, 2026-08-26. D39 isolated pylint and mypy and swept for the rest, and the sweep covered two tools out of fifteen. flake8 reads setup.cfg, tox.ini and .flake8 from the tree under audit, which sets its own thresholds and select-lists — the score moving with a config file is exactly what P2 forbids, and Decision 9 forbids reading the tree's configuration at all.

--isolated is passed now. The wider fix is that the classification stopped being prose: ADAPTER_CONFIG carries three states — REFUSED, ISOLATED:<flag>, and DATA_ONLY — and only the last is a human judgment, which now has to name the configuration surface it was judged against. The isolated entries are checked against the argv the adapter actually builds, so an isolation flag claimed in the table and absent from the invocation fails.

Closing tests: test_every_adapter_is_classified_for_what_its_configuration_executes, test_an_isolated_adapter_actually_carries_its_flag and test_no_selectable_adapter_needs_the_audited_trees_configuration in tests/test_analyzer_config_isolation.py.

D65 — Closed: two ADRs still had eslint running (Low)

Found by audit, 2026-08-26. Decision 9 refuses eslint outright — an eslint flat config is a JavaScript program, so honouring it means executing the audited tree. The adapter declares executes_audited_configuration and selection drops it on every run.

Two ADRs written before that decision still told the reader otherwise. ADR 006 listed eslint in its Detected tier, the tools "used when already on PATH", and ADR 008 used eslint as its worked example of a tool whose threshold the agent sets from the rubric — and offered "or a generated config" as a way to do it, which is the same forbidden thing spelled differently. An operator following either page installs a tool this agent will always refuse.

Both are amended in place rather than rewritten, because the reasoning around them is still correct about findings. ADR 008's escape hatch is withdrawn: a threshold is rubric-drivable through argv or not at all.

The falsifier sweeps the refused adapters and reads the docs for lines that claim this agent runs a tool, so the next tool refused for the same reason is caught without being named. The previous review of these documents forbade exactly one sentence in SECURITY.md and missed everything either side of it.

Closing test: test_no_document_presents_a_refused_analyzer_as_one_this_agent_runs in tests/test_analyzer_config_isolation.py.

D66 — Closed: an empty history window says which kind of empty (Medium)

Found by audit, 2026-08-26, reopening D56 one layer down. D56 established that files_changed: 0 is not a measured zero and marked every history rate not applicable. It then told every reader the same reason: "no commit falls inside the history window."

Three different repositories produce that zero, and the sentence is false for two of them. Nothing was committed in the window. Or every commit in it is a merge, dropped by --no-merges because a merge's numstat re-reports churn already counted on the branch. Or commits landed and touched only files this audit does not scan — a lockfile, a vendored tree, an excluded directory. Only the first is a quiet window; the other two are windows this agent filtered to empty, and a reader told the first sentence goes to check their clone depth.

window_commits counts both — commits in the window, and commits this audit will read — with two rev-list --count calls that parse nothing. The evidence reason and the rendered sentence are chosen from those counts, and both fall back to the original wording for a report written before the counts existed.

Closing tests: test_a_merge_only_window_is_not_reported_as_a_quiet_one, test_a_window_filtered_to_empty_says_so and test_a_genuinely_empty_window_still_reads_as_empty in tests/test_history_window.py.

D67 — Closed: the sweeps resolve dotted spellings (Medium)

Found by audit, 2026-08-26 — the ninth evasion of the same two sweeps, and the third time their name resolution has been wrong.

Version one matched a literal attribute, so import subprocess as sp and from subprocess import run walked past. Version two resolved the bound name, which is right for import subprocess and wrong for import xml.etree.ElementTree: that statement binds xml, and the call is written xml.etree.ElementTree.fromstring(...), whose base is an attribute chain rather than a plain name. The sweep looked for a Name and found an Attribute, so an unguarded XML parse — the entity bomb D46 exists to stop — was reachable with a green suite.

Resolution is by dotted spelling now, matched by prefix. More importantly the resolver has its own falsifier: ten spellings of an unguarded parse, five of a spawn, and four unrelated calls that must not be flagged. It earned its place immediately — it caught a regression in the rewrite that classified every from x import member as a module alias, which the two sweeps it serves would have reported as clean.

Closing tests: test_every_spelling_of_an_unguarded_parse_is_seen, test_every_spelling_of_a_spawn_is_seen and test_the_resolver_does_not_widen_to_unrelated_calls in tests/test_ast_reading_resolves_spellings.py.

D68 — Closed: the declarations dimension names its source (Medium)

Found by audit, 2026-08-26. DECLARATION_CRITERIA requires cyclomatic complexity, declaration lines and cognitive complexity, because the built-in path fails a declaration on any one of the three and a rate built from a narrower set is not comparable to it. lizard emits the first two.

So for a JavaScript repository with lizard installed and nothing else, _declaration_pressure returns None and the built-in brace scanner scores the dimension — on every run, by construction. The fallback is correct. It was silent, which P8 forbids: a declarations rate with nothing saying what produced it.

It also made a decision page wrong. Decision 10's amendment justified keeping JavaScript by citing lizard, jscpd and multimetric as baseline-tier adapters that read it. The ruling — JavaScript stays because there is a detector and it can be scored — is exactly right about the brace scanner and was never about the pool; the page credited the pool for work the pool cannot do here. Corrected in place.

dimensions_declined now rides on the coverage document with the missing concepts named, and the coverage section renders it.

Closing tests: test_lizard_alone_cannot_drive_the_declarations_dimension, test_the_fallback_is_attributed_rather_than_inferred and test_the_declined_dimension_reaches_the_reader in tests/test_declaration_source.py.

D69 — Closed: P1 discloses that the history window moves (Medium)

Found by audit, 2026-08-26. DEFAULT_SINCE is 12 months ago, resolved by git against the wall clock at the moment the audit runs, and it is not configurable. P1 promised "same tree, config, pinned analyzer versions and scan history in, same evidence, findings and score out" and named three excepted fields, none of which is this.

An unchanged tree audited a week apart reports different churn, hotspot, coupling and ownership rates. No input changed and the grade moved.

The determinism test cannot see it and neither can the runs it is built on: they audit the same tree seconds apart, which is precisely the interval over which a twelve-month window cannot shift. Fifth of the same shape — a promise kept green by a check narrower than the promise.

Disclosed rather than removed. A fixed absolute window would make every report a different question over time, and per-repository pinning is a config decision nobody has asked for. The falsifier holds the page to the constant, so changing the window without changing the disclosure fails.

Closing test: test_the_history_window_is_disclosed_as_clock_relative in tests/test_determinism.py.

D70 — Closed: the POSIX claim runs on both POSIX platforms (Medium)

Found by audit, 2026-08-26, reopening D63. test_ci_runs_only_platforms_the_package_claims forbade windows in runs-on and was treated as demonstrating the claim. Operating System :: POSIX covers macOS as well as Linux, and CI had only ever run one Linux image, so half the declared platform was claimed and never executed. The check was narrower than the claim it protected — the same finding this register keeps recording.

Why macOS was never added is the more interesting half. Seven test fixtures pinned PATH=/usr/bin:/bin when scrubbing the environment for a child git. On the macOS machine this project is developed on, that pin is a shim: /usr/bin/git is Xcode's stub, Command Line Tools are unavailable, and every one of those fixtures dies with xcrun: error: invalid active developer path before git runs.

That is the entire local baseline — 101 failures and 191 errors, diffed run after run as "known" — from one line, and none of it says anything about the product. A real regression landing in any of those seven files had 292 places to hide, and this project's own discipline of diffing the whole suite against a baseline is what made that survivable-looking.

tests/_git_path.py resolves the directory of the git actually on PATH; the environment stays scrubbed. A macos-latest job runs the suite, and the platform test now requires a runner for every family the classifier claims rather than merely forbidding one it does not.

Closing tests: test_ci_runs_only_platforms_the_package_claims in tests/test_platform_claim.py, and the suite itself on the macOS runner.

D71 — Closed: reading a repository does not let git rewrite it (High)

Found by the macOS CI runner on 2026-08-26, hours after that runner was added for D70 — the second defect that runner has paid for.

Every git command this package runs is a read: log, rev-list, status, rev-parse. But git runs housekeeping of its own after many commands, and housekeeping repacks objects and writes commit-graphs inside .git. So an audit that promises never to write the tree it audits was letting git write it, on our behalf, one directory down.

It surfaced as .git/objects/maintenance.lock appearing between the before and after snapshots in test_audit_returns_the_report_without_writing_source_or_reports and test_the_tool_takes_a_format_argument_and_never_prompts.

Why now, on identical product code. Auto-maintenance triggers on accumulated loose objects rather than on every invocation. D66 added two rev-list --count calls per audit and pushed a latent defect over the threshold. Two earlier CI runs of the same code passed, which is exactly what a probabilistic check looks like from the inside — and why the fix is pinned to the argv rather than to a snapshot of a temporary directory.

gc.auto=0 and maintenance.auto=false are prepended to every git command in run_git, the one place this package builds a git argv, which is what makes the promise checkable at all.

The fix was right and the test still failed. macOS went red again on the very next run, with the same maintenance.lock. Auto-maintenance detaches: git maintenance run --auto returns immediately and the work lands milliseconds later. The scheduler here was not the product at all — it was the fixture's own git commit, which sets the repository up before the audit is ever called, and whose maintenance then fires inside the window between the before and after snapshots.

Same commit, three CI runs, two failures and one pass. That is the signature of a race, and it is the second time in this entry that a probabilistic symptom pointed at the wrong culprit.

So there are two halves. The product no longer triggers maintenance, which is the real guarantee and is pinned to the argv. And the suite no longer schedules it either: a session-scoped conftest fixture exports GIT_CONFIG_COUNT/KEY/VALUE, which reaches every git the suite runs, product and fixture alike. Its falsifier asks git what it resolved rather than asserting the variables are set — the difference between proving conftest ran and proving git listened.

Closing tests: test_every_git_command_disables_gits_own_housekeeping in tests/test_git_argv.py, and test_the_suites_own_git_has_maintenance_disabled in tests/test_git_read_only.py.

Found by audit, 2026-08-26. D38's refusal carried write_instead: the canonical path the entry resolved to, so the user could correct their config. It was the more helpful message, and it told whatever host reads server_info where a symlink the user named actually points — a directory the user never put in their config, published over the transport by the very mechanism added to make refusals visible.

D48's rule is that host paths do not cross the transport. A helpful refusal is not an exception to it, and "we surfaced it so it would not be silent" is not a reason to surface more than the user supplied.

The entry itself is still echoed, because the user wrote it. Its target is not ours to publish. repair names the flow instead: grant the root again through setup, which stores the path it resolved to at the moment of consent.

Closing test: test_a_non_canonical_grant_is_refused_and_said_so in tests/test_grant_only_user_tier.py, which now asserts the resolved target appears in no field of the refusal.

D73 — Closed: the one git spawn that is not run_git (High)

Found by audit, 2026-08-26, one commit after D71 closed. _backfill._git builds its own argv and does not go through run_git, so it ran rev-list without gc.auto=0 / maintenance.auto=false — and commits_in_range() reaches it before any worktree exists. D71's whole claim is that reading a repository cannot let git rewrite it, and this was a read that could.

The closing test could not see it. It parsed git_tools.py alone, because that was where the fix lived. A rule about every git command, held by a check that read one file — and there was exactly one git command elsewhere in the package.

The sweep reads every module now: any argv whose first element is the literal "git" must carry READ_ONLY_GIT_CONFIG, and it asserts it found at least two spawns, because finding one is the state that hid this.

Closing test: test_every_git_command_disables_gits_own_housekeeping in tests/test_git_argv.py.

D74 — Closed: an incoherent window explains itself as unknown (Medium)

Found by audit, 2026-08-26. commits_in_window and commits_considered are not HistoryEvidence members, so nothing upstream validates them, and _empty_window_reason asked only isinstance(..., int). True is an int. A report carrying commits_in_window: true, commits_considered: 0 was told, confidently, that every commit in its window was a merge. Negative counts and a subset larger than its set did the same.

D66 exists to stop a wrong reason being stated confidently, so the fix is not a better guess: an incoherent pair earns the least specific answer. Bools rejected, negatives rejected, and a non-merge subset that exceeds the set it is drawn from rejected.

Closing test: test_an_incoherent_pair_of_counts_earns_the_least_specific_reason in tests/test_history_window.py, seven payloads.

D75 — Closed: the doc sweep recognises more than three sentences (Medium)

Found by audit, 2026-08-26. D65's closing test matched three exact phrasings, lifted from the two sentences it was written to catch. Adding "maintainability-agent runs eslint whenever it is installed" to an ADR left it green. A check shaped like the defect it already found is not a check — the same criticism this register has recorded five times, now about a test written to prevent it.

Two wrong versions before the third. Matching every verb flagged nine lines of history: the register recording the defect, the roadmap listing tools this project does not replace, an ADR quoting an old experiment. Then proximity exoneration turned out to be gameable in the most direct way possible — a claim inserted directly beneath the paragraph refusing eslint was exonerated by it, which is backwards, since a refusal nearby is exactly where a false claim does the most damage.

What it checks now is the harm D65 actually names: acquisition language beside a refused tool's name, exonerated only by the same sentence, in operator-facing documents. The three incident records are exempt by name and with reasons, because describing the old behaviour is what they are for.

Closing test: test_no_document_presents_a_refused_analyzer_as_one_this_agent_runs in tests/test_analyzer_config_isolation.py, verified against four distinct phrasings.

D76 — Closed: P1 is held to the window a report is built with (Medium)

Found by audit, 2026-08-26. D69's closing test compared the prose to the DEFAULT_SINCE constant. Changing history_section's default to "24 months ago" left the constant untouched and the test green, with the disclosure describing a window nothing used.

It now reads the effective window — the signature default — requires it to equal the constant the page quotes, and checks by AST that report.py does not pass a window of its own, since a call site that overrides the default makes the disclosed value fiction.

Closing test: test_the_history_window_is_disclosed_as_clock_relative in tests/test_determinism.py.

D77 — Closed: a comment cannot stand in for an install (Medium)

Found by audit, 2026-08-26. The test asserting CI installs every pip-installable adapter asked whether the slug appeared anywhere in the workflow file. Deleting flake8 from the install line and leaving # flake8 is installed by this step behind kept all fourteen green while the adapter went uninstalled.

It parses the YAML now and tokenises the actual pip install commands, with comments stripped first. That immediately found a real gap the substring version could not: ruff reached CI only through .[dev], so the pool step never named it. Rather than carve an exemption for it, the claim was made literally true.

Closing test: test_ci_installs_every_pip_installable_adapter in tests/test_ci_installs_the_analyzer_pool.py.

D78 — Closed: the JS complexity number is about the code (High)

Found by audit, 2026-08-26. Decision 10 keeps JavaScript because this project has a detector that can score it, and D68 made the fallback to that detector visible. The audit's sentence is the finding: visibility is not accuracy. D68's closer checks that the built-in scanner is attributed and never asks whether its number means anything.

COMPLEXITY_RE counted every ? character as a decision point. In JavaScript ? is three operators and only one is a decision. ?. is defensive member access. ?? is one decision written with two characters, so a bare \? counted each one twice. ? ternary is the branch the rule was written for.

Reproduced at the shipped thresholds:

function pick(u) {
  return u?.user?.profile?.settings?.theme
      ?? u?.user?.prefs?.theme
      ?? "light";
}

Cyclomatic 12, status warn. Its McCabe number is 1. An eight-way fallback chain scored 15, one under the hard gate. It fires hardest on exactly the modern JavaScript this project claims to score, which is P7: a score issued where the thing measured was not the code. After the fix, 3 and 8, and genuinely branching code is unchanged.

The audit's other JS observations — regex literals unmasked, brace-free bodies charged flat, object-literal arrows not detected as declarations — are disclosed limitations in _ranges and _cognitive and are not closed here. This entry closes the one that produced numbers that were simply wrong.

Closing tests: test_the_scored_complexity_is_the_functions_complexity, test_a_defaulting_expression_does_not_warn and test_python_complexity_is_unchanged_by_the_javascript_fix in tests/test_js_complexity_operators.py.

D79 — Closed: a grant records what it was, not only where (High)

Found by audit, 2026-08-26 — the fourth predicate in three days, and the one whose prediction was written into the finding: "D38 is the one that will be filed again if this round is closed by tightening resolve()==self without an inode."

Two holes, both reproduced. Path.resolve() is not strict=True, so a directory nobody has created "resolves to itself" and was honoured — hand-write it, get no refusal, then create it or mount over it. And resolve() preserves case, so on APFS /USERS/<user>/... exists, resolves to itself, and was treated as a product-made grant.

The pattern is the finding. Four rules compared better and better strings, and each moved the hole rather than closing it, because a path is a name and names alias: symlinks, case-insensitive volumes, bind mounts, a directory created after the question was answered. Version three's own docstring said so — "a bare path with no record of what it resolved to when it was granted cannot be defended" — and then honoured bare paths anyway.

So this version stops comparing names. persist_root_grant records the directory's device and inode at the moment of consent, and a grant is honoured only when the directory at that path is still that directory. A directory swapped for another under the granted name — identical by name, which is why every previous rule honoured it — is refused by the same check that refuses a swapped symlink, and so are bind mounts, case variants and ghost paths, none of which a spelling rule could reach.

Where identity stops, disclosed rather than implied. Deleting a directory and immediately recreating it is a case (device, inode) cannot reliably see: ext4 hands the inode straight back, so the recreated directory is identical by every field recorded at consent. APFS does not reuse. The first version of the falsifier asserted a refusal for that case, passed on macOS — including the macOS runner added the day before — and failed on Linux CI. That is a claim wider than its mechanism, which is this register's own recurring defect arriving from the other side, and it was caught by infrastructure added two days earlier for a different reason.

A hand-written entry carries no identity and is refused. That is the deliberate consequence rather than an oversight: it is exactly the bare path the rule cannot defend. Existing users are asked once more, which D38 established is not an error.

Not claimed: that this ends the series. What is claimed is that the thing compared is no longer a spelling.

Closing tests: test_a_grant_to_a_directory_that_does_not_exist_is_refused, test_a_case_variant_spelling_is_refused, test_a_granted_directory_swapped_for_another_is_refused and test_inode_reuse_is_the_disclosed_limit_of_identity in tests/test_grant_only_user_tier.py.

D80 — Closed: the population floors are bounded from below (High)

Found by audit, 2026-08-26, in a table of "checks that cannot fail the property they name". test_no_calibration_member_is_unscoreable_by_the_scale_it_calibrates bounds the floors from above — a floor may not exceed the corpus minimum. Nothing bounded them from below.

Set files_scanned to 1 and every check in that file stays green while a one-file repository collects a verified grade. That is the exact result ADR 005 exists to prevent and the one P7 names: a score issued as a consequence of not looking.

Asserted against the behaviour rather than the constant — one file, one declaration, no score, however the table is edited — with the invariant also stated on the table so someone editing it sees why it has two sides.

Closing tests: test_a_hello_world_repository_is_never_scored_whatever_the_floors_say and test_the_floors_are_bounded_from_below_as_well_as_above in tests/test_population_floors.py.

D81 — Closed: the witnesses no longer share fate with what they watch (High)

Found by audit, 2026-08-26, reopening D71 at the layer an earlier version of the same finding did not reach. D73 fixed the backfill argv and widened the sweep; this is the other half.

The conftest guard exports GIT_CONFIG_* for the whole suite so that fixtures cannot schedule git maintenance. It also covers the product. So the "never writes the tree" snapshot tests — the only witnesses to D71 — would stay green if READ_ONLY_GIT_CONFIG were deleted from run_git tomorrow, because maintenance would not fire either way.

Demonstrated rather than argued. With the settings removed from run_git, all 36 snapshot tests pass and the new witness fails. It records the argv of every git a real audit spawns and asserts each carries the settings: it watches the product, not the environment, and is deterministic where the snapshot was probabilistic.

The same finding named D70's closer: runs-on: macos-latest with run: true satisfied it, so POSIX could be "demonstrated" by a job that does nothing. The macOS job now has to run the suite.

Closing tests: test_a_real_audit_spawns_no_git_without_the_read_only_settings in tests/test_git_read_only.py, and test_the_macos_runner_actually_runs_the_suite in tests/test_platform_claim.py.

UAT audit of 199fb1b. D72 removed the resolved path from server_info's refusals because D48 forbids host paths crossing the transport. authorize_repository kept doing it: the user names innocent, and its PathNotAllowed tells the host secret-target.

D72's falsifier asserted the resolved path appears in no field of the refusal dictionary. It never read the exception. One door was closed and its neighbour was left open by a check shaped around the door that had been reported — the register's recurring defect, and the second time in three days that a fix's own closer defined the fix's scope.

The refusal echoes the spelling the user supplied, which is theirs already, and says how to obtain a grant without resolving anything. The sibling refusal for a non-directory is checked for the same leak.

Closing tests: test_a_refusal_does_not_tell_the_host_where_a_symlink_points and test_a_missing_directory_refusal_does_not_name_the_resolved_path in tests/test_authorization_freshness.py.

D83 — Closed: a standing grant is re-checked at use, not at start-up (High)

UAT audit of 199fb1b — and the most consequential finding of the round, because D79 was closed without it and reads as complete.

allowed_roots() runs once, when the server is constructed, and produces a tuple of paths. authorize_repository then asked one question: is this request inside one of them? So the identity D79 records at consent was verified at start-up and never again. Swap the granted directory afterwards and the audit keeps authorizing whatever now sits at that path for the life of the process — and an MCP server is long-lived, on the surface this product calls primary.

Reproduced in-process: grant project, rename it away, recreate it with pwned.txt inside, ask again. Authorized. Only a restart refused it.

This is D38's original shape returning a fifth time: the in-process seam was already right, and a later read of a stored fact went around it. There the stored fact was a path and the read followed a symlink; here the stored fact is an identity and the read was not happening at all. Every version of this entry has fixed where the check compares and left when it runs alone.

Persisted grants are re-validated at each authorization. Launch roots — --allow-root, the environment, the working directory — are not: they carry no recorded identity because they are this process's own configuration rather than a standing consent, and re-checking them would refuse every ordinary launch.

Closing tests: test_a_swapped_directory_loses_its_grant_without_a_restart and test_a_launch_root_is_not_re_checked in tests/test_authorization_freshness.py. The check lives in _stored_grants beside the rule it applies, and the audit door re-raises it as PathNotAllowed so the transport keeps translating a declared refusal rather than seeing a crash (D48).

D84 — Closed: a nested list's members are shaped too (Medium)

UAT audit of 199fb1b. _shaped_inside has a branch that validates list items and it only ever ran for a top-level list. So {"paths": {"include_extensions": [1]}} was accepted: the value is a list, which is all that was asked.

The audit then matched no file, exited 0, and reported a clean scan of nothing with forty source files "unread". That is worse than a crash and is exactly the concealment D53 exists to prevent — a type error turned into an apparently valid empty result, which a reader cannot tell from a repository that genuinely has no source in it.

The dict branch now recurses, so a member is checked wherever it lives. Refused by name: paths.include_extensions[0] must be str, not int.

Closing tests: test_a_nested_list_member_of_the_wrong_type_is_refused_by_name and test_a_valid_nested_list_still_loads in tests/test_config_shape.py.

D85 — Closed: the version string is a claim like any other (High)

UAT audit. Acceptance testing for 1.0 was about to run against an artifact naming itself 0.9.1 and Development Status :: 3 - Alpha. A tester would report a version that is not the thing under test, and this project already shipped nine releases whose contents did not match what they claimed (D23).

The remedy applied was 1.0.0rc1, Development Status :: 4 - Beta — not 1.0.0, on the reading that the release plan tags 1.0 at 8.10 and a candidate is therefore still short of that gate. The falsifier held the three copies of the version together and refused a bare 1.0.0 while 8.10 still said the tag waits on 8.9.

That remedy was wrong and is reverted; see D100. The diagnosis stands, and so do three of its four tests: three copies of a version are one fact, the maturity classifier is a claim about the same artifact, and a support table that omits the shipped line reads as the shipped line being unsupported. All three pass at 0.9.1 — they were never about which version, only about the copies agreeing.

What did not stand is the direction. 8.8 is the acceptance run and 8.9 the hostile audit of the artifact that passed it; neither had happened, so "candidate for 1.0" asserted the outcome of a gate nobody had opened. Reading the bar as 8.10 alone is what let the promotion through. The entry written to say a version string follows evidence rather than intention was itself the intention.

Closing tests: test_a_final_1_0_0_is_not_claimed_before_its_gates_close, test_the_maturity_classifier_matches_the_version and test_security_support_covers_the_shipped_version in tests/test_version_claim.py.

Amended 2026-09-20 by D198. This entry also cited a fourth test, which checked that pyproject, config.VERSION and __init__.__version__ agreed. Its name is deliberately not repeated here: a citation is what a reader runs, and naming a retired test would put it back in the register as one. There are no longer three copies to disagree: the literal lives in __init__ alone, config re-exports it and pyproject derives it. The test was retired with its premise rather than left passing vacuously, because a check that polices copies which no longer exist makes reintroducing one look safe. The agreement property is now structural, held by test_the_version_has_one_source. The earned half of this entry — that a version string follows evidence rather than intention — is untouched.

D86 — Closed: the JS scanner sees the file's actual functions (High)

UAT audit, continuing D78. That entry fixed ? arithmetic and left the rest disclosed; the objection is that disclosure is not a score.

Two defects, both reproduced. function f() { return /a?b?c?d?e?/; } scored cyclomatic 6 — the regex literal's contents were read as code, because masking scrubbed comments and strings and not regex literals. And { onSave: (a) => {...}, onLoad: function (b) {...} } produced no declarations at all, which is how a React or Node codebase writes most of its interesting logic: the audit scored whatever loose function statements happened to sit beside the handlers and reported the file examined.

Masking now recognises a regex literal where a value may begin, which is the standard heuristic and the whole disambiguation JavaScript offers without a parser — a / b ? 1 : 2 is untouched. The brace scanner detects name: members, a prefix that cannot be a control keyword and so does not collide with if (. The example above now scores 1, and all three declarations are found.

Closing tests: test_the_scored_complexity_is_the_functions_complexity and the cases beside it in tests/test_js_complexity_operators.py.

D87 — Closed: the macOS job runs the suite, not the word (Medium)

UAT audit. D81 required "pytest" in the macOS job body. echo pytest satisfies that, and so does a pytest invocation naming one file — which would let the job stop running the product's suite without anything noticing. D77 had taught the same lesson one job over: a comment is not an install.

The run commands are parsed now, and one of them has to be a whole-suite pytest invocation rather than contain the word. Verified against both bypasses.

Closing test: test_the_macos_runner_actually_runs_the_suite in tests/test_platform_claim.py.

D88 — Closed: why the argv recorder is the only witness (Medium)

UAT audit. D81's recorder watches the argv; the promise is about the tree; the 36 snapshot tests still run under the suite-wide GIT_CONFIG_* guard and would not notice if READ_ONLY_GIT_CONFIG vanished. The objection is correct.

Two attempts at a tree witness failed, and the second failure is the answer. Removing the guard was not enough — maintenance is threshold-driven and merely allowed to fire. Making the environment hostile with gc.auto=1 was not enough either, and that one passed with the settings deleted, which would have shipped a test that passes either way into the entry that exists to prevent them.

The reason is that git runs housekeeping after commands that write — commit, merge, fetch — and this package runs none of them. The maintenance.lock that opened D71 was scheduled by a fixture's own git commit, which is what D71's second half already concluded. No audit of an unmodified repository can produce the write a snapshot would catch, so a passing snapshot proves nothing in either direction.

What is checkable is the premise, and that is what is checked: every git subcommand the audit spawns is a read, flags included. It found one the list had missed — git branch --show-current — on its first run. If a writing subcommand is ever added the reasoning stops holding and this fails, while the argv guarantee, which does not depend on the premise, stands on its own.

Closing test: test_the_product_runs_only_git_commands_that_read in tests/test_git_read_only.py.

D89 — Closed: gating CI pins, on the wrong platform (Medium)

UAT audit. P1's determinism is conditional on pinned analyzer versions. The gating pipeline had installed the pool unpinned, on purpose, so that an unchanged main going red because an analyzer shipped is a signal rather than a silence.

That disclosure is now closed into the workflow. constraints/analyzers.txt checks in the Python 3.12 resolver output for the twelve pip-installed analyzers and their dependency closure, and the gating jobs (verify, audit) install through it. A green PR gate therefore certifies the pinned-input condition P1 actually states, not merely that the suite passed against that day's analyzer releases.

The old drift signal is kept rather than deleted. The weekly scheduled run creates a throwaway virtualenv, installs the same top-level pool unpinned, freezes what pip resolved, and fails on a diff against the checked-in constraints. Analyzer movement still turns the scheduled run red, but the merge gate no longer floats.

Reopened 2026-08-27, unclosed on review. Three defects in the fix, two of them the class D97 names:

The drift check could never pass. It diffed the constraints file -- eight lines of provenance comments -- against raw pip freeze output, which has none, so the scheduled run went red every week whether or not anything drifted. This file's own header says a pipeline that fails on something advisory teaches people to ignore it.

Its test could not see that. It asserted the string diff -u constraints/analyzers.txt was present. Appending || true -- which neuters drift detection entirely -- left it green. The second version asserted the step mentions stripping, and replacing the helper body with cat left that green. The third runs the workflow's own normaliser against the real constraints file and checks its output could have come from pip freeze.

The pin does not match the platform it constrains. The closure was resolved on macOS arm64; verify, audit and the drift job all run ubuntu-latest. The pinned install may not resolve there at all, and the drift comparison would report platform-divergent closures as analyzer drift, weekly, forever.

Closed 2026-08-28. resolve-constraints was dispatched against 717da4d and its artefact is now the checked-in file: Python 3.12.14 on Linux-6.17.0-1022-azure-x86_64-with-glibc2.39, the platform verify, audit and analyzer-drift actually run on. The xfail(strict=True) marker did what it was put there to do — it turned into XPASS(strict) the moment the file landed, so the closure could not be taken without also removing the marker.

The residual was written on the assumption that the two closures would diverge. They do not, and that is worth recording rather than assuming: both platforms resolve the same forty-two packages, no entry is platform-conditional, and the only differences are platformdirs 4.11.4 → 4.11.5 and ruff 0.16.4 → 0.16.5, two releases that shipped in the day between the resolutions. So the weekly drift comparison was never going to report platform divergence as analyzer drift on this pool. That was a real risk when it was written and it is now a measured non-risk, which is a different thing from a guess that happened to hold.

Closing tests: test_the_constraints_were_resolved_on_the_platform_the_gates_run_on, test_the_gating_jobs_install_through_the_constraints_file and test_the_scheduled_drift_job_floats_and_can_actually_fail in tests/test_analyzer_pinning.py.

Mutation: the platform check was broken twice, both outside the sample it asserts over — it reads the constraints file and the workflow, and neither mutation touched the test. Rewriting the provenance header to name macOS again failed it; deleting every comment line from the constraints file failed it on the "records no provenance" clause. Restoring the artefact passed it. The claim is about which platform resolved the pins, and both mutations make that claim false in the two ways it can be false: wrong platform, or no way to tell.

The platform residual was closed: dispatched resolve-constraints, checked in its artefact, removed the marker.

D90 — Closed: a stale grant does not veto a launch root (High)

Found by audit, 2026-08-26, against a fix made the same day. D83 re-checks persisted grants at use, and applied that check to any request a stale grant happened to cover. So launching with --allow-root <base> while holding a stale grant for <base>/project refused <base>/project — which the launch root authorized on its own, with no consent involved.

A freshness rule that revokes access this process's own configuration granted is not a tightening; it is a denial of service. The fix for a security defect introduced an availability one, on the same day, in the same function.

And D83's closer passed throughout. Its fixture launched on the granted directory's parent, so that scenario always had independent launch cover and never isolated the standing grant it claimed to test. The test was green because of the over-broad behaviour rather than despite it. Fixed here: the fixture launches on a sibling, which is what makes the grant the only thing under test.

Authorization now separates its two sources. A launch root — the flag, the environment, the working directory — authorizes on its own. Only when a persisted grant is the sole cover does freshness decide.

Closing tests: test_a_launch_root_still_authorizes_despite_a_stale_grant_beneath_it and test_a_stale_grant_with_no_launch_cover_is_still_refused in tests/test_authorization_freshness.py.

D91 — Closed: the config door stops publishing paths too (High)

Found by audit, 2026-08-26. D82 removed the resolved path from authorize_repository's refusal. authorize_config, eleven lines below it, published two: a caller naming innocent.json was told the symlink's target and the canonical repository path.

D82's falsifier read authorize_repository and stopped there, so the fix that removed one disclosure left a larger one beside it untouched. That is the fourth door in this family — server_info (D72), the repository refusal (D82), the not-a-directory refusal (D82), and now this — and each was found only when someone looked at the next one along.

Both refusals here name the spelling the caller supplied, which is theirs already, and resolve nothing into the message.

Closing test: test_the_config_refusals_name_no_resolved_path in tests/test_authorization_freshness.py.

D92 — Closed: an audited repository cannot run code in this process (Critical)

Found by audit, 2026-08-26, and the most serious defect found in this project.

Decision 9: "this agent never executes the audited repository's code, and its configuration is code." That was enforced on the analyzer adapters — eslint refused outright, pylint and mypy pointed at os.devnull, and a sweep held every adapter to a classification. Git was never asked the question.

core.fsmonitor is a repository config key naming a command git executes. worktree_status runs git status on every git-backed audit, which is the default path. Reproduced: a repo whose .git/config sets core.fsmonitor to a script in its own tree ran that script in the auditor's process, and the payload wrote a file into the worktree — which the MCP door separately promises never happens. The product then returned ?? PWNED as the status string, having created PWNED itself.

READ_ONLY_GIT_CONFIG existed and disabled housekeeping (D71). One rule about git writing, none about git executing.

The list is wider than the demonstrated vector — core.hooksPath, core.pager, core.sshCommand, core.alternateRefsCommand, diff.external, credential.helper, protocol.ext.allow — because the command set grows, and the last rule scoped to the commands of the day missed the one spawn that lived elsewhere (D73).

Residual, disclosed rather than closed: content filters (filter.<driver>.clean) and diff.*.textconv execute too and are keyed by a driver name from the tree's own .gitattributes, so no fixed -c disables them. Verified they fire on a worktree-content git diff and not on this package's only diff, which compares two commits by name and status.

It also falsifies D88's reasoning. That entry argued no tree-level witness for the git promise could exist because "this package runs only reads". status is a read that executes. The premise was wrong, not merely the conclusion.

Closing tests: test_the_audited_repository_cannot_choose_what_this_process_runs and test_worktree_status_on_a_hostile_repository_changes_nothing in tests/test_git_read_only.py.

D93 — Closed: TypeScript type members are not declarations (High)

Found by audit, 2026-08-26, against D86 — closed the same morning.

_PROPERTY_RE matches name: (args) =>, which is also how TypeScript writes an interface member. So onSave: (a: string) => void; counted as a one-line, complexity-1 function.

The cost is not cosmetic. Forty files of real functions scored insufficient and were refused a grade. The same forty with an interface of three typed arrows each reported 160 declarations, crossed the population floor, diluted band pressure fourfold, lifted declaration_size from 1.4 to 3.0, and issued a verified C. The type members are what bought the letter — P7 (a grade from a population that is not there) and P3 (withheld evidence improving the result) in one step.

Type blocks are skipped now: inside interface X { … } or type X = { … }, nothing is a declaration.

Closing tests: test_type_members_are_not_counted_as_declarations and test_a_real_object_literal_member_is_still_found in tests/test_js_declarations.py.

D94 — Closed: string-keyed members are declarations too (High)

Found by audit, 2026-08-26, the other half of D86. Masking blanks string literals before any pattern runs, so "onSave": (a) => { arrives as : (a) => { and the name is gone. Quoted keys were invisible, and a lone function helper() beside them still marked the file examined — D86's original shape exactly.

D86's closer used the unquoted instance, which is the one masking does not destroy. Some keys must be quoted: "on-error" is not a valid identifier.

The name is recovered from the line before masking touched it.

Closing tests: test_a_string_keyed_member_is_a_declaration and test_a_sibling_function_does_not_stand_in_for_the_handlers in tests/test_js_declarations.py.

D95 — Closed: a regex after a control paren is not code (Medium)

Found by audit, 2026-08-26, third against D86. _VALUE_MAY_BEGIN lists the positions where a / opens a regex literal. It includes return, which is the keyword D86's own closer used. It does not include ).

So if (x) /a?b?c?d?e?/; scored complexity 7 against a McCabe number of 2, while return /a?b?c?d?e?/; — the tested case — scored 1.

) is the one position a character cannot decide: if (x) /re/ opens a value and f(x) / 2 is division. The paren is walked back to and the token owning it is asked.

Closing test: test_a_regex_literal_is_masked_wherever_a_value_may_begin in tests/test_js_declarations.py.

D96 — Closed: the last two doors stop publishing resolved paths (High)

Found by audit, 2026-08-26. D72 removed a resolved path from server_info, D82 from authorize_repository, D91 from authorize_config. Two more were still open: baseline_path, and config.repository_path — which runs on an ordinary audit whenever the repository's own config names a path, so a symlinked history.jsonl published its target to the chat host.

Six doors, one family, and each was found only when someone looked at the next one along. Every closer read the function it was written for.

Closing test: test_no_repository_scoped_path_refusal_names_what_it_resolved_to in tests/test_authorization_freshness.py.

D97 — Closed: the class behind thirty entries (High)

2026-08-27: the same type of defect kept coming back, so why? Is there a root cause — one class, easily addressed — rather than symptoms treated one at a time?

That had not been asked. Thirty of the ninety-six entries above are one mechanism, closed thirty times, each with a bespoke fix and no question about why the next one arrived. The mechanism, the three clauses that answer it, and why mutation testing was not catching it are written up under The falsifier standard at the top of this file.

The short version: a check written from the instance that motivated it rather than from the claim it defends, and a mutation drawn from the same sample as the assertion, so the mutation confirmed the sample and said nothing about the claim. Every time an auditor broke one of these, they mutated a member the test did not name.

What it cost to leave unnamed: four adapters of fifteen swept, three sentences of a document, one file of a package, one keyword of a value list, a pytest that echo pytest satisfied, a diff that || true neutered, and a security rule enforced on every analyzer and never asked of git.

tests/test_falsifier_standard.py enforces clause two over the suite: sixteen tests derive a population by walking the filesystem, and every one of them must now assert it found something. Four did not. Clause three is required to be stated on every entry from D97.

The control had the defect three times while being built, which is the strongest evidence that it needed to be mechanical rather than remembered: it counted ast.walk as a filesystem walk and reported twenty of thirty-three violations that were not; and it accepted any population being asserted, so a test binding two populations was covered by guarding either. That last one was found by mutating a guard the check did not name — the clause catching its own author.

The detector's own non-empty guard is deliberately not cited below. It defends the detector rather than the claim, and tools/prove_falsifiers.py showed it passing at the base — correct for a guard, wrong for a closer. A citation region that named it would be claiming a proof it cannot give.

Closing tests: test_a_sweep_asserts_its_population_is_not_empty in tests/test_falsifier_standard.py and test_entries_from_the_cutoff_state_what_their_mutation_broke in tests/test_mutation_recorded.py.

Mutation: removed the population guard from test_release_plan::test_the_release_plan_table_is_measured_not_remembered and separately from test_written_record. Neither is named by test_a_sweep_asserts_its_population_is_not_empty, which derives its subjects from the suite rather than listing them — so breaking either is a member the closing test does not know about. The first draft of the closer survived the test_files mutation and was fixed because of it.

D98 — Closed: a fix from a second author, reviewed (High)

Reviewed 2026-08-27, the first change whose code came from a second author. Three defects, and what they were is more useful than that there were three.

The drift check could never pass. It diffed constraints/analyzers.txt — eight lines of provenance comments — against raw pip freeze output, which has none. Non-empty every run, drift or no drift. The comparison normalises both sides now.

Its test could not see that. It asserted the string diff -u constraints/analyzers.txt appeared. Appending || true neuters drift detection entirely and left it green. My replacement asserted the step mentions stripping — and replacing the helper body with cat left that green. The third version extracts the workflow's own normaliser and runs it against the real constraints file, checking the output is something pip freeze could have produced.

Two string-shaped checks in a row, from two authors, on the day D97 named that exact class. The standard is easy to state and hard to apply, which is the argument for the parts of it that are mechanical.

The pin does not match the platform it constrains — see D89, which this reopened.

What the review says about the experiment. The work was good on the parts that were asked for: real resolved versions, honest provenance, the closure decision made explicitly rather than silently, gates pinned, drift moved rather than deleted. Its failure mode was the same as mine — a check written from the artifact rather than the behaviour — which is evidence that the split does not fix this class by itself. What found all three was mutation from outside the sample, run by someone who was not the author. That is the part that transfers.

Closing tests: test_the_gating_jobs_install_through_the_constraints_file, test_the_scheduled_drift_job_floats_and_can_actually_fail and test_product_intent_describes_the_arrangement_the_workflow_implements in tests/test_analyzer_pinning.py.

Mutation: appended || true to the drift comparison, and separately replaced the normaliser body with cat. Neither is named by the closing tests, which locate the step by its name line and then execute what it defines — so both break a member the tests do not enumerate. The first two versions of the closer survived one of these each.

D99 — Closed: every control was self-report (High)

Miles Parker, on the thread where this work was described, 2026-08-27: "agents are always in control over both what is being tested and the test. An agent that introduced a bug and mutated the test to cover it up would look no different externally from one that functioned perfectly."

He is right, and nothing built before this addressed it. D97's standard is enforced by tests the same agent can edit. The *Mutation:* line is the author's account of what they broke. Every control in this register was, ultimately, self-report.

The benign version is commonplace, and it happened three times in one day. A test fails; the author decides the test was wrong; the author edits it. test_git_argv pinned an exact tuple and the tuple legitimately grew. test_mcp_server matched refusal text D82 changed. test_declaration_grading searched a whole document for "42". Each edit was probably correct. Nothing external distinguished the test was wrong from the test was made to agree with the code, and that is exactly the distinction Miles is asking for.

tools/prove_falsifiers.py is the first control that does not take the author's word. For every entry added since the base commit it reads the tests the entry cites, builds a scratch worktree, restores everything to the base except the files defining those tests, and requires each cited test to fail there. A test that passes without the change did not need it.

Two design faults, both found by running it rather than reasoning about it. Excluding all of tests/ could prove no entry whose fix lives in test code — D97 added guards to four test files, so keeping tests/ kept the guards and D97's own closer looked unproven. And git checkout restores tracked files without deleting ones the change added, so constraints/analyzers.txt survived the revert intact.

On its first honest run it found two real problems. test_there_are_sweeps_to_check guards the detector rather than defending the claim and passes at the base — correct for a guard, wrong for a citation, so it is no longer cited. And test_entries_from_the_cutoff_state_what_their_mutation_broke passed vacuously at the base, where no entry reaches the cutoff and the list it asserts over is empty: D97's own clause-two defect, inside D97's own closer, invisible to every check that existed the day before.

What it does not do: it cannot stop an agent editing the prover, the workflow, or the register. Those need enforcement outside the agent's reach — branch protection, CODEOWNERS, a human reading a diff. It closes the case where the code and its check are written together, which is the case that was actually producing defects here.

Closing tests: test_the_citation_region_stops_at_the_next_field, test_every_citation_spelling_the_register_uses_is_read and test_the_tool_is_wired_into_the_pipeline in tests/test_prove_falsifiers.py.

Mutation: neutered the proving job with || true, and separately appended a *Mutation:* line naming a test to an entry to confirm the citation region ignores it. Neither is a member the closing tests enumerate — they locate the job and the region by structure — and the tool itself was run against bc21f4d to confirm four cited falsifiers fail without their changes and two do not.

D100 — Closed: the artifact promoted itself to a 1.0 candidate (High)

2026-08-28: nobody had authorized a v1.0 candidate. D85 moved pyproject, config.VERSION and __init__.__version__ to 1.0.0rc1 and the classifier to Beta, added a 1.0.x row to SECURITY.md and a 1.0.0rc1 — unreleased section to the changelog, on 2026-08-26. No authorization for any of it is recorded anywhere in this repository.

The defect is not the number, it is the claim. This project's stated rule — in D85's own text — is that a version string is a claim like any other and follows evidence rather than intention. The evidence for "release candidate for 1.0" is 8.8, the acceptance run. 8.8 has not run. So the entry written to stop a version from outrunning its evidence promoted the version past 8.8 and 8.9 in the same commit, and passed, because its falsifier only ever refused a bare 1.0.0. A candidate suffix is not a smaller claim than the release; it is the same claim about the same gate, made more quietly.

And the record could not say what authorized it. Nothing in the repository recorded a decision to promote the version, and a commit is not an authorization. So the fix does not depend on recording one: a version claim is checked against what has actually been released.

The revert. Every copy returns to 0.9.1 and Development Status :: 3 - Alpha, the state before D85; the 1.0.x support row and the changelog section are removed. This restores a prior state rather than choosing a new number, because picking one would be a second release decision nobody authorized.

The bar is release history, not a plan row. The first version of the falsifier refused a 1.x claim while the release plan's 8.8 acceptance row was open. A question killed that anchor: why cite 8.8 when 0.9.1 was the latest release? That was right. 8.8 is a sentence in a document, and citing it made the bar sound like it was about a 1.0 programme when the operative fact is far simpler — what has actually shipped. The check now reads git tag: nothing may declare a major line above the newest release, and on the day a 1.x is tagged the bar lifts by itself. No document gets a vote.

Closing tests: test_no_copy_claims_a_major_line_above_the_latest_release in tests/test_version_claim.py. The version copies are found by sweeping pyproject.toml and src/ rather than by a typed list of three, and an empty tag list fails the check instead of passing it.

Mutation: two, both outside the sample the test names. Setting config.VERSION to 1.0.0rc1 — a copy the assertion never names, reached only through the sweep — failed it. Then, with that mutation still in place, tagging the tree v1.0.0-mutation-probe made it pass. That second one is the load-bearing proof: it shows the bar is read from release history rather than compiled in, because the same declaration is legal the moment a 1.x release exists. The probe tag was deleted and the version restored, both verified.

D101 — Closed: the write-safety primitives are POSIX by decision (High)

_safe_write.py:203 calls os.fchmod; _skill_install.py:34 builds os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW. Neither exists on Windows. A windows-latest probe on 2026-09-05 produced 165 failed, 1972 passed, 33 errors, and 94 of those failures are those two attributes — 81 fchmod, 13 O_DIRECTORY.

What makes it High rather than a portability note. These are the security-relevant paths: the bounded, symlink-refusing write that the D34/D36/D96 family exists to enforce, and the handle-based root the skill installer opens once so every read is relative to it. The mechanism that keeps an audited tree from redirecting where this agent writes is implemented on file descriptors that Windows does not have. That is a statement about the product's architecture, not about its tests.

It also falsifies the reason the README gives. The platform section says Windows is unclaimed because "the test suite creates symlinks with no platform guard". Symlink failures are not among the top causes at all. The stated reason has been the wrong reason.

Open. Operating System :: POSIX remains the claim and the probe remains continue-on-error, so nothing shipped is untrue — but the finding is recorded rather than left in a wrap-up, and a release does not cut while it is open. Falsifier: pending — it belongs to whoever decides whether the answer is portable primitives or a stated architectural limit, and naming a test before that decision would prejudge it.

Half closed. _safe_write no longer calls os.fchmod where it does not exist; the call stays handle-based on POSIX, because os.chmod on the staging path is the very time-of-check/time-of-use hole this module closes, and the portable-looking swap would buy Windows by weakening POSIX. Where the call is skipped the mode stays as mkstemp set it — 0600, stricter rather than looser.

The rest is closed by decision, not by code. Decided 2026-09-05: this tool is not broken to fix Windows support. _skill_install opens its root with O_DIRECTORY|O_NOFOLLOW and works relative to that descriptor, which is what makes a symlink swap between check and write impossible (D18). Windows has no equivalent, and the portable rewrite — validate a pathname, then write to it — is the hole itself.

So the limit is stated rather than removed: the write-safety mechanism is POSIX by construction, Operating System :: POSIX stands, and the probe stays continue-on-error. The rule is now recorded: an unsupported platform never buys green by weakening a guarantee the supported platforms rely on.

What this entry corrects for the record is the reason. The README said Windows was unclaimed because the suite creates unguarded symlinks. Symlink failures were not among the top causes; 94 of 165 were these two attributes. The stated reason was wrong even though the conclusion was right.

Closing test: test_the_package_claims_the_platform_it_is_tested_on and test_ci_runs_only_platforms_the_package_claims — they guard the closure continuously, holding the classifier at POSIX and refusing a Windows runner in any job that gates something.

Falsifier proof: not applicable — closed by a decision to leave a platform unsupported, with no code change to revert. The os.fchmod guard is a portability edit whose absence changes nothing on any platform the project claims, so a revert proof cannot fail for the right reason on POSIX. What is asserted instead, continuously rather than once: test_the_package_claims_the_platform_it_is_tested_on holds the classifier at POSIX, and test_ci_runs_only_platforms_the_package_claims refuses a Windows runner in any job that gates something. Those guard against the closure being quietly widened, which is the risk here — not against the fix regressing.

Mutation: none yet — the finding came from running the whole suite on a platform nobody had run it on, not from breaking a member.

D102 — Closed: two audit-test functions exceed the repo's own budgets (Low)

tests/test_tree_chosen_spawn.py — _argv0 (17 lines, complexity 9, cognitive 17) and _tree_bin_modules (25 lines, complexity 9, cognitive 18), both warn. Raised by this project's own code scanning on PR #171 and resolved there without being acted on, because the cycle that produced that file keeps the implementor out of tests.

That was the correct action for the cycle and the wrong end state for the ledger: a finding nobody may act on is still a finding. Filed so the constraint is visible and the work is assignable to whoever owns tests.

Assigned to the test writer. The implementor does not cross into tests to clear a ledger.

Closed. _argv0 is a dispatcher: Invocation argv lives in _invocation_argv0, subprocess.run argv in _subprocess_run_argv0. _tree_bin_modules walks files; _tree_chosen_invocations decides whether an Invocation is tree-chosen. Both original functions read ok under detect_functions (cognitive 17 → 2 and 18 → 3).

Closing test: test_the_spawn_helpers_stay_inside_the_repo_budgets in tests/test_tree_chosen_spawn.py. It runs detect_functions on the file and fails if any helper is warn or fail — the same detector that produced the finding, on a member the test does not name.

Mutation: fold _invocation_argv0 back into _argv0, or _tree_chosen_invocations back into _tree_bin_modules. The cited test fails without naming either original function.

D103 — Closed: _jobs is over the cognitive warn line (Low)

tests/test_platform_claim.py — _jobs (36 lines, complexity 12, cognitive 16), warn. Mine, on PR #169, resolved without being acted on with the reasoning that splitting a 36-line indentation walk spreads one algorithm across two functions for no reader benefit.

That reasoning may well be right. It is still a judgement made by the author of the code against the project's own published thresholds, which is the shape D99 is about. Filed so the call is reviewable rather than resolved away in a PR thread.

Closed. Finding the section, skipping blanks and comments, and grouping by indentation were three concerns in one function. _under_jobs takes the first two: _jobs goes from cognitive 16 to 6, and all three helpers in the file read ok. The reasoning offered in the PR thread — that splitting helps no reader — was wrong, and the thresholds were right.

Closing test: test_ci_runs_only_platforms_the_package_claims — it passes over the split helpers, so the guard's behaviour is unchanged by the refactor, which is the property a refactor must have.

Falsifier proof: not applicable — the closing evidence is a measurement, and the behaviour is deliberately identical before and after. _jobs went from cognitive 16 to 6 and all three helpers read ok under this project's own detect_functions; the behaviour the guard tests is deliberately identical before and after, so no test can fail at the base for the right reason. A refactor that changed behaviour would be a different entry.

Mutation: none, as D102. The entry exists because the author of the code judged their own work against the published threshold and resolved the thread, which is the D99 shape rather than a defect in a detector.

D104 — Closed: two operator-named reads had no validation at all (High)

SonarCloud pythonsecurity:S8707, two instances, both MAJOR, and the rule's own wording names this product's setting: "LLMs running this code with faulty CLI arguments can escape file system restrictions."

They were the only two path-taking entry points that never went through any validation. Every other path in this project is bounded by repository_path; these two legitimately point outside the audited tree, so bounding is the wrong control — and no control was the shipped answer.

The concrete harm was a hang, not a traversal. read_text on a FIFO blocks forever, and on /dev/zero consumes memory until the process dies. Measured before the fix: a bare read_text on a FIFO had to be killed at four seconds. "Denial-of-service via crafted config files" is in this project's own published in-scope list, so the tool was vulnerable to something it invites people to report.

read_operator_file opens the path once and checks and reads through that handle — the discipline _safe_write already uses for writes. os.stat(path) followed by path.read_text() resolves the name twice, so what was measured and what is read can differ; O_NONBLOCK is what makes the check reachable at all, since opening a FIFO for reading otherwise blocks before any validation runs. It requires a regular file and bounds the size. Deliberately not a symlink refusal: the operator named the path and controls it, and the audited tree's own default is a different question that discovered_config already answers.

The residual was resolved as a false positive on 2026-09-05, and is recorded here rather than only in the dashboard. After the fix one S8707 remained on config.py, because the rule's remedy is to bound the path to a directory and --config legitimately points outside the audited tree. The grounds, as entered on the issue: the path is an operator-supplied CLI argument rather than tree content; every tree-derived path here already goes through repository_path; the call is validated through a single handle; and the exploitable behaviour — the hang — is fixed and covered. Residual disclosure is bounded to files that parse as this tool's own JSON schema.

That is a suppression, and this project counts suppressions as findings when other people make them (_conformance.SUPPRESSION_MARKERS reads NOSONAR). Writing it down is the difference between a judgement and a silence: a reader of the code sees nothing, so the register carries it. The other four SonarCloud vulnerabilities in this family closed as FIXED rather than dismissed — three S2612 and one S8707.

Closing test: test_an_operator_named_path_must_be_a_regular_file — a FIFO and a device are refused at both doors rather than read.

Mutation: drop the S_ISREG check and the FIFO case hangs the suite rather than failing it, which is why the test asserts the refusal type rather than a timeout.

D105 — Closed: "Update branch" cannot be used on this repository (Medium)

The Every commit declares who wrote it gate failed on PR #175 with no agent at fault. The failing commit was the merge that gh pr update-branch created — GitHub's own — and %G? reported E, signature unverifiable, because GitHub's web-flow key is not in .github/allowed_signers.

The obvious fix was wrong and nearly shipped. The trailer step uses --no-merges; the signature step deliberately does not, and says why in its own comment: "a merge carries the conflict resolution its author wrote, so an unsigned merge is unattested content, and skipping it let exactly that through (found by audit)." Adding --no-merges there to make CI convenient would have undone an audit-driven control — the same temptation as swapping os.fchmod for a path-based chmod earlier the same day, and the same answer.

So this closes as a process rule rather than a code change: bring a branch up to date by rebasing, never with the "Update branch" button or gh pr update-branch. The alternative — trusting GitHub's signing key in allowed_signers — is a real option and is deliberately not taken here, because it widens what the gate accepts to buy a button.

Closing test: test_the_signature_gate_still_checks_merge_commits — it pins the absence of --no-merges in the signature step, so the convenient "fix" for the button fails loudly instead of landing.

Falsifier proof: not applicable — nothing in the product changed; the guard pins a control against a future edit rather than defending one made here.

Mutation: none — nothing was edited. The evidence is PR #175's failing run and the rebase that cleared it.

D106 — Closed: two determinism assertions compare an expression with itself (Low)

python:S5863, twice, both MAJOR to SonarCloud and both real as written:

The intent is sound and worth keeping: each calls a function twice and asserts the results match, which is how determinism is tested here. The shape is indistinguishable from the mistake the rule exists to catch — a reader cannot tell a deliberate double invocation from a typo, and neither can the analyser. Binding the two calls to named variables says which one it is:

first = render_html(report, records)
second = render_html(report, records)
assert first == second

These sit in test files, which belong to the test writer. The implementor does not cross into tests to clear a ledger.

Both tests now bind separate calls to first and second, then compare the results with a message naming the determinism failure. Neither call was removed and no suppression marker was added.

Closing test: tests/test_three_presentations.py: test_html_is_deterministic; tests/test_evidence_properties.py: test_the_whole_sweep_is_deterministic. These preserve the determinism checks; they also pass before this clarity refactor and do not prove SonarCloud issue resolution. The analyser's confirmation awaits CI.

Mutation: none — this changes assertion clarity, not the tested behaviour. Both independent invocations remain; no production mutation or claim of a new population falsifier is made.

D107 — Closed: two SonarCloud findings resolved as false positives (Low)

Recorded for the same reason D104 records its own: a resolution taken in a dashboard is invisible to a reader of the code.

pythonsecurity:S8707, config.py. The same finding D104 closed. It reopened under a new key because the one-handle rewrite moved the code after the first resolution was applied, and SonarCloud tracked the result as a new issue rather than the same one. Nothing about the code or the reasoning changed. The lesson is sequencing: resolve a finding after the code that produces it has settled, or the resolution is spent on a line that is about to move.

pythonbugs:S2583, _evidence_view.py:197. The analyser reports if is_complete(score) as always false. It is not: status has three values — insufficient, complete, incomplete — and the two equality checks above it are not exhaustive. Verified by calling status_sentence with a complete score and getting the complete sentence back. A genuine analyser error rather than a judgement call.

Closing test: test_a_withheld_score_names_the_path_out — it asserts "complete" in status_sentence(tiny), which is the exact branch the analyser calls unreachable. The claim was checkable against a test that already existed, and the first draft of this entry cited a test that did not — caught by test_every_closing_citation_names_a_test_that_exists, which is the guard for precisely that.

Falsifier proof: not applicable — no code changed; two external findings were resolved and the grounds recorded.

Mutation: none.

D108 — Closed: the suppression scan reports markers it only reads about (Medium)

SUPPRESSION_MARKERS carries a comment promising exactly the thing it did not do:

Per language, because the vocabulary differs and a single regex over all of them matches prose: a type: ignore in a docstring explaining the convention is not a suppression, and neither is this comment.

Splitting the regexes per language was the whole mitigation, and it does not mitigate this at all: #\s*noqa\b matches the marker wherever it appears, docstring included. Four versions shipped with the promise in the file and nothing enforcing it.

It surfaced the first time the rule ran where a false positive costs something. --staged blocked a commit of git_tools.py whose only offence was a docstring reading "would report a decade of accumulated # noqa as though this commit had just written them" — a sentence about suppressions, in the module that reads them. The conformance record has carried the same exposure since 1.4.0, where it is quieter: a spurious line in a report nobody is blocked by.

Both callers now go through one function, _conformance.markers_in, which refuses a match that is quoted — immediately preceded by a backtick or quote character, or inside an open backtick span. One rule, asked twice, because a hook stricter than the gate it belongs to blocks commits CI would pass.

The narrowing is deliberate and stated: a marker written into prose with no quoting at all is still reported. Quoting is the reliable signal; widening past it is guessing at English.

Closing test: test_a_quoted_marker_mention_is_not_a_suppression in tests/test_scope_conformance.py.

Mutation: reverting _conformance.markers_in to a bare pattern.search makes test_a_quoted_marker_mention_is_not_a_suppression fail: suppressions_added({"m.py": [(1, "…# noqa…")]}, set()) reports a backtick-quoted mention. The trailing-directive half stays green.

D109 — Closed: a hollow test rode in because its neighbours were new (Medium)

Two defects, one cause, and the second is the one that matters.

The instance. test_staged_refuses_flags_it_would_have_to_ignore asserted that the refused flag was named on stderr — "--changed-only" in err — and passed on a tree where --staged did not exist as a flag at all. argparse prints its usage banner on any error, and that banner lists every flag the test names, {json,markdown,html} included. Exit 2 and a matching substring were both satisfied by the parser rejecting --staged itself. The test defended nothing.

An audit had already caught this shape one level down: a bare --changed-only fails on argparse's nargs, not on the refusal, so each case had to carry its argument. It did. The trap simply existed again one level above, where the flag under test is the one that does not exist at the base.

The class. tools/prove_falsifiers.py saw it and passed anyway. Its added-file check asked whether every test in a new file passed at the base; with fourteen real falsifiers beside it, the hollow one rode in. It printed the evidence — 14 of 15 fail without the change — and exited 0. On the same run, the added-in-place check held each new test in test_scope_conformance.py individually and named both.

So a test's obligation to falsify something depended on how new its neighbours happened to be. That is the gate this project relies on to keep its own tests honest, and 1 in 15 walked through it on the first serious use.

Both callers now report every individual test that passes at the base. The file-level Covers existing behaviour: escape is unchanged: a whole file that is deliberately about pre-existing behaviour is still exempt, stated rather than assumed. The refusal test now asserts the sentence only this tool writes — --staged does not take — which no usage banner contains.

Closing test: tests/test_prove_falsifiers.py: test_one_hollow_test_in_an_added_file_is_still_reported. It stubs _prove to report one pass among many failures against a real added file and asserts the pass is named. Written before the fix and red against it, reproducing the 14 of 15 line verbatim.

Mutation: restoring the len(nodes) == len(tests_in(...)) condition — the whole-file rule — makes the closing test fail, because the single passing node is no longer reported. The falsifier was written by the implementor rather than the test writer, and is owed an adversarial read for that reason.

D110 — Closed: the README's images are invisible on PyPI (Low)

pyproject.toml sets readme = "README.md", so the README is the PyPI long_description. A repository-relative path has no repository to resolve against there: docs/cover.png resolves against pypi.org and 404s, so the image does not display. GitHub resolves the same path correctly, which is why this is invisible from inside the repository.

Correction to this entry's original evidence. It first claimed the live page had been checked and "contains no .png at all". That check was not valid: curl against pypi.org/project/maintainability-agent/ returns a 3 KB challenge page with no description body in it — zero occurrences of the word "maintainability", let alone an image. The conclusion was drawn from a page that was never the project page. The defect is real on the mechanism above, and the fix is the standard one, but the verification claimed here did not happen and the entry should not have said it did.

2.9.0 added a second image on the same terms.

The ordering is why this was filed open rather than fixed in the same change. The fix is an absolute raw.githubusercontent.com/.../main/ URL for both images, and that URL cannot resolve until the files are on main. Fixing it before the merge would have replaced two images that render on GitHub with two that render nowhere, which is worse for the reader actually looking — exactly what happened when the workflow image was first added with an absolute URL and showed as broken.

Closed as the first change after 2.9.0 merged, once both URLs returned 200. Both are now absolute.

Closing test: tests/test_readme_claims.py: test_no_readme_image_is_repository_relative. It asserts the property — no image reference in the README is repository-relative — rather than scraping a rendered page, which is what made the original evidence worthless. A test that depends on fetching a third-party page proves whatever that page felt like returning.

Mutation: restoring either image to its docs/... path fails the closing test by name. The check is a property of the file rather than of a render, so it holds without reaching the network.

D111 — Closed: writing about the escape phrase triggered it (Medium)

D108's defect, one layer up, found within the hour by fixing D109.

prove_falsifiers lets a test opt out of the revert proof by declaring Covers existing behaviour: in its docstring. The phrase was matched as a substring of the source, so a docstring explaining the escape exempted the very test doing the explaining. D109's own falsifier — written to prove the gate reports a hollow test — was reported as

exempt — covers existing behaviour: `` escape still exempts a whole file that

a "reason" sliced out of a sentence about the mechanism. The gate printed that line and passed, so the test written to close a hole in the gate was excused by the same gate, on the strength of a sentence describing it.

This is the same rule as D108 and the fix is the same shape: declares_exemption refuses an occurrence that is quoted — preceded by a backtick or quote character, or inside an open backtick span — and both call sites go through it, so the file rule and the per-test rule cannot disagree.

One correction during the fix, worth recording because it went the wrong way first: the rule initially counted a docstring's own opening """ as a quote and rejected a legitimate exemption an audit had written. A guard that refuses real declarations is not stricter, it is broken. Only a triple quote before the phrase reads as a delimiter now; a single one reads as somebody quoting it mid-sentence.

Closing test: tests/test_prove_falsifiers.py: test_a_quoted_escape_phrase_does_not_exempt_anything. It asserts a backticked mention and a double-quoted mention exempt nothing, that a bare declaration does, and that a docstring opener does not block one.

Mutation: restoring COVERS_EXISTING in text makes the closing test fail on its first assertion. Removing the triple-quote clause makes it fail on its last, and re-breaks the exemption in test_a_real_trailing_directive_is_still_a_suppression.

D112 — Closed: Python was scored against its own comments and strings (High)

The flagship language, the one this project is written in, and the one most of the calibration corpus is measured through.

declaration_ranges returned Python's raw lines to score complexity against, while every other language received a comment- and string-masked copy. The function's own docstring said as much — "JS/TS/ HTML score against a comment- and string-masked copy" — and Python was simply not in that list. So every keyword in every comment and every string literal counted as a decision.

A branchless four-line function scored 4, its every point supplied by one comment and one string. 384 branch points on this repository's own 121 files came from prose inside docstrings, which a line-local masker would not have caught either: a triple-quoted string spans lines and survives it.

Masking now uses tokenize, CPython's own lexer, so what counts as a comment or a string is decided by the language rather than by a pattern written from memory — which is how this arrived.

Closing test: tests/test_python_complexity.py: test_a_python_comment_is_not_a_branch, test_a_string_literal_is_not_a_branch, test_a_docstring_is_not_a_branch.

Mutation: returning lines instead of mask_python_lines(lines) in declaration_ranges restores complexity 4 for the branchless function and fails all three.

D113 — Closed: Python's boolean operators were never counted (High)

The Python branch pattern looked for && and || — C's operators, in a language that spells them and and or. Every boolean operator in every Python file this tool has ever measured was invisible: 3,199 of them in this repository alone, and if a and b or c scored 1 where standard cyclomatic complexity is 3.

Python now has its own branch set, derived from the language reference rather than inherited from the C family. case counts and match does not, by the arms-not-header rule shared with Go, PHP, Ruby and Fortran.

Closing test: tests/test_python_complexity.py: test_boolean_operators_are_decisions.

Mutation: removing and|or from PYTHON_COMPLEXITY_RE scores the sample 2 instead of 4.

D114 — Closed: the fix for D112 blanked the code inside f-strings (Medium)

Introduced by the repair, found by the same method that found the original.

On Python 3.11 an f-string is a single STRING token, so blanking the token whole removed the expressions written inside {…} — which are code, and frequently the only place a ternary or a comprehension appears. Found by hand-counting a function the oracle still disagreed on after D112 and D113 were fixed: this project said 7 and the grammar says 11.

Only the literal text is blanked now; {{ and }} are escaped braces and hold nothing.

Closing test: tests/test_python_complexity.py: test_expressions_inside_an_f_string_are_counted and test_the_literal_text_of_an_f_string_is_still_not_counted.

Mutation: blanking the whole token again scores the sample 1 instead of 5.

D115 — Closed: a ? in type position was counted as a ternary, in five languages (High)

One defect, five spellings, and the third appearance of D78's shape.

Each decides nothing and each scored one. The first three share the C-family pattern, so a single fix closed them; PHP and Swift carry their own and were fixed beside it.

A ternary needs both halves, so a following : is now required. The cost is a ternary split across lines, which is not counted — under-reporting, which is the direction this project errs in, and it is stated beside the pattern.

lizard has this same defect in its TypeScript reader, which is why the divergence is declared rather than followed. A harness built to chase agreement would have re-introduced the bug to match the oracle.

Closing test: tests/test_grammar_constructs.py: test_every_construct_agrees_with_an_independent_implementation over the C#, TypeScript, PHP and Swift fixtures.

Mutation: dropping the lookahead requiring a following colon scores every optional parameter and nullable type in those fixtures as a decision.

D116 — Closed: unconditional constructs were counted as decisions (Medium)

goto in Go and PHP, and loop in Rust. All three transfer control without deciding anything: a goto is an edge, and Rust's loop has no condition — the if … break inside it is what decides.

All three were added in the twenty-four hours before the audit, each with a confident justification written beside it. C had it right all along and never counted goto, which is what the C fixture demonstrated.

Closing test: tests/test_go_declarations.py: test_goto_is_not_a_decision; tests/test_rust_declarations.py: test_an_unconditional_loop_is_not_a_decision; tests/test_php_declarations.py: test_do_while_and_match_are_branches.

Mutation: restoring goto or loop to their patterns scores each fixture one high.

D117 — Closed: a multi-way header was counted beside its own arms (Medium)

Go's select, PHP's do … while, Swift's repeat … while, and Rust's wildcard _ => arm.

The rule was already written down for Fortran — select case is a header whose cases are the branches, and counting both scores the construct and its first arm — and it was not applied when four more languages were added. A select with two cases has two paths; select {} with no cases blocks and decides nothing. A do { … } while (cond) is one loop with one condition, carried by its while. A wildcard arm is a default, and a default is not a test.

The Go case is the sharpest: the C-family pattern already counted case, so Go's select dispatch was measured correctly before it was touched. A test written from a wrong intuition failed, and the code was changed to satisfy the test rather than the grammar.

Closing test: tests/test_go_declarations.py: test_select_is_counted_at_its_cases_not_its_header; tests/test_rust_declarations.py: test_a_wildcard_match_arm_is_not_a_decision.

Mutation: restoring select, do or repeat to their patterns, or counting a wildcard arm, scores those fixtures one high.

D118 — Closed: Rust refused to count the operator that returns early (Medium)

? was excluded on the stated reasoning that it "propagates an error and decides nothing", by analogy with JavaScript's optional chaining (D78). The analogy is false. let x = f()? continues or returns early, and the operator expands to a match with two arms — two paths, one decision. That idiomatic Rust is full of them is a fact about Rust, not a reason to under-count it.

Closing test: tests/test_rust_declarations.py: test_the_error_operator_is_a_decision.

Mutation: excluding the operator from RUST_COMPLEXITY_RE scores the fixture 1 instead of 2.

D119 — Closed: no measurement was ever checked against a second implementation (High)

The defect behind the eight above, and the only one about method rather than code.

Every branch keyword set in this project was written from somebody's knowledge of a language and checked against examples written from the same knowledge. That is not evidence; it reads as evidence because the prose around it is confident, which is worse than reading as a guess.

The measurable consequence: on this repository's own source, this project agreed with an independent implementation on 448 of 985 declarations — 45%. It is now 968 of 1000.

The instinct existed and stayed a one-off. test_the_built_in_reading_agrees_with_lizard cross-checked one Fortran function and pinned the number rather than running the tool — "so the suite stays offline", though lizard is a local library. One function, one language, frozen. This class of defect survived in nine others.

tools/complexity_oracle.py compares per declaration across a tree, and tests/fixtures/grammar/ holds one fixture per language exercising the constructs its specification defines, checked construct-by-construct in CI. Thirteen languages are covered, and the coverage is counted in readers rather than fixtures — see D120, which is the language a count of fixtures missed. Only COBOL has no independent implementation available; it is named in the test as checked against itself alone, which is stated rather than implied. HTML and fixed-form Fortran share their branch readers with languages that are checked.

Divergences are declared with the grammar reasoning that settles them, never silently absorbed: the check exists to hold this project to the specification, not to lizard. Chasing a second implementation is the same error as trusting the first, with an extra step — and lizard's own TypeScript reader carries D115.

Closing test: tests/test_grammar_constructs.py: test_every_construct_agrees_with_an_independent_implementation, test_the_fixture_covers_more_than_one_construct, and test_every_declared_divergence_is_still_real.

Mutation: deleting a fixture, or shrinking one below five constructs, fails the coverage guard; a divergence that stops being real fails the staleness guard.

D120 — Closed: Python counted the wildcard case _, which five other languages correctly do not (Medium)

Found by the check D119 built, one language after it was believed finished — which is the argument for the check.

Python had no grammar fixture. Twelve languages were added to tests/fixtures/grammar/ and the flagship language — the one this project is written in, and the one carrying D112, D113 and D114 — was not among them. The guard that should have caught that, test_there_is_a_grammar_fixture_to_check, only asserts the directory is non-empty, which twelve fixtures satisfy while a thirteenth language goes unchecked. Coverage is now counted in branch readers, since the reader is the thing that can be wrong, and a reader with no second implementation has to be named.

With the fixture in place, eleven of twelve constructs agreed exactly. The twelfth: case _ was counted as a decision. It is Python's default — a wildcard always matches, so it adds a path without a decision to reach it. This project already refuses to count Go's default:, Rust's _ => and the C family's default: (D117), so the same dispatch scored differently depending on which language it happened to be written in.

lizard counts it, so this is a declared divergence rather than a silent one, and the reasoning is the grammar plus this project's own rule — not the second implementation. case _ if guard: is excluded here and counted by its if, which is the decision; case _name: is an ordinary capture pattern and case [_, x]: still has a pattern to match, and both still count.

Closing test: tests/test_python_complexity.py: test_the_wildcard_case_is_a_default_and_not_a_decision; tests/test_grammar_constructs.py: test_every_branch_reader_is_checked_against_a_second_opinion and test_no_declared_gap_actually_has_a_fixture.

Mutation: restoring a bare case to PYTHON_COMPLEXITY_RE scores the wildcard arm as a decision; deleting the Python fixture, or removing a reader from the declared-gap map while it has no fixture, fails the coverage guard.

D121 — Closed: an empty parameter set was reported as a pass (Medium)

The grammar checks are parametrized over the fixture directory. Delete every fixture and pytest marks those tests skipped rather than failing them — and a skip exits 0, which every gate reads as green. So the checks D115, D119 and D120 cite defended nothing once the evidence they read was removed, which is the precise failure test_the_fixture_covers_ more_than_one_construct was written to prevent: "the cheapest way to make the check above pass is to shrink what it looks at." Shrinking it to zero was cheaper still, and invisible.

It passed locally and failed in CI, and the difference is the point. pytest 8.3.4 treats a node id whose parameter set is empty as a usage error and exits non-zero; pytest 9.1.1 creates a skipped placeholder and exits 0. The local interpreter said the falsifiers were proven. The gate on the pull request said four of them were not. The gate was right.

empty_parameter_set_mark = "fail_at_collect" in pyproject.toml says this suite-wide, and it was not enough, which is the part worth keeping. The first fix set only that, CI came back failing in exactly the same way, and the reason is that the falsifier gate reverts every file except the ones defining the cited tests — pyproject.toml included. A gate that reverts your configuration cannot be satisfied by configuration. The guard has to live in the file under test, so _fixtures() now raises when the directory is empty, and the setting stays as the suite-wide backstop.

Absence read as a pass is the defect this project keeps finding in itself, and this is the same sentence one layer down: the report says so for an unparsed language, --check says so for a language with no scanner, and the test suite said nothing at all.

Closing test: tests/test_grammar_constructs.py: test_there_is_a_grammar_fixture_to_check, and the parametrized test_every_construct_agrees_with_an_independent_implementation — with tests/fixtures/grammar/ emptied, _fixtures() raises and collection errors, where before the parametrized checks skipped and reported a pass. Verified under pytest 9 with empty_parameter_set_mark forced back to skip, which is the state the falsifier gate reverts to.

Mutation: returning [] from _fixtures() instead of raising makes an emptied fixture directory report green on pytest 9, with or without the pyproject.toml setting.

D122 — Closed: f-strings were not masked at all on Python 3.12 and later (High)

D112 again, in the one place the fix for it did not reach, and on the versions most people run.

PEP 701 changed how an f-string is tokenised. Through 3.11 the whole literal is a single STRING token whose braces have to be walked by hand, which is what D114 built. From 3.12 the tokeniser does that walk itself: the prose arrives as FSTRING_MIDDLE tokens and the code between the braces as ordinary ones. The masker recognised only the 3.11 shape, so on 3.12+ an f-string matched neither branch and passed through entirely unmasked.

f"check for errors and warnings: {value}" scored 3 — its for and its and counted as decisions in a branchless function.

This project's development interpreter is 3.11 and its CI runs 3.12, so the whole suite was green locally while the shipped behaviour was wrong for every user on a current Python. The macOS job caught it. The Linux job would have, had the lint step not failed first and stopped it.

Closing test: tests/test_python_complexity.py: test_an_f_string_is_masked_on_every_supported_python, which asserts both halves — prose not counted, braces counted — because a fix for either alone has already broken the other (D114).

Mutation: removing FSTRING_MIDDLE from the blanked token types scores the prose sample 3 instead of 1 on any Python 3.12 or later.

D123 — Closed: the README told readers that four parsed languages were not parsed (Medium)

Found by reading the shipped page, three lines below the table that contradicts it.

The language-support section lists Go, Rust, PHP and Ruby as parsed, with a scanner and a fidelity note for each. The paragraph immediately after the table read:

Any language not in that table — Go, Rust, Ruby, PHP, Kotlin, and the rest — is not parsed for declarations by the built-in scanner. Its files still count toward repo size, but the built-ins produce no function-size, complexity, duplication or dead-code findings for them.

Four of the languages named are in the table. A reader deciding whether this tool was worth pointing at a Go repository would have been told, by the release that shipped Go support, that it does nothing for Go.

docs/roadmap.md carried the same sentence in the other direction — "no scanner is scheduled for any of them", naming Go, Rust, Ruby and PHP in the release that wrote all four scanners.

A third instance sat in "What it analyzes": "function size and complexity — exact ranges for Python via ast, brace-bounded for JS/TS/JSX/TSX/HTML", which silently omits the eleven other languages with dedicated scanners. Not a contradiction, but the same understatement of the product, and it had drifted twice already. That bullet no longer enumerates languages at all — it points at the table, so there is one list to keep true rather than three.

All three survived because the claim and its contradiction are far apart and each reads correctly alone. The lead paragraph of the README was guarded (D120's sibling check) and this paragraph was not: the guard asked whether every parsed language is named, and the failure here is a language named in the wrong sentence.

test_no_parsed_language_is_named_as_unparsed now reads the paragraph that makes the negative claim, in both files, and holds it against the languages the scanner actually reads. It asserts the marker sentence still exists first, so the guard fails loudly if the prose moves rather than silently checking nothing.

Closing test: tests/test_claimed_languages.py: test_no_parsed_language_is_named_as_unparsed.

Mutation: restoring either sentence's original language list fails with the four languages named — verified against the README's original text before committing.

D124 — Closed: the release checklist omitted a step its own gate requires (Low)

The v2.11.0 tag was pushed and the release build failed on test_the_release_plan_table_is_measured_not_remembered: the plan still named v2.10.0 as the last tagged version.

The guard worked exactly as designed and caught it before anything reached PyPI — the publish and GitHub-Release jobs were skipped, so no artifact was distributed and the version number stayed reusable.

The cause is a checklist that does not name the step. release.yml documented three: bump the version, add the changelog entry and merge, then tag. Nothing said to re-measure docs/release-plan.md in that same commit, and the row cannot be updated afterwards — the test compares it against git tag, and the tag is cut from the commit that carries the row. So the omission is invisible on main and only appears once the tag exists, which is the most expensive moment to find it.

The checklist now names it as step 3, with the reason, so the next person reading the procedure does not have to rediscover the ordering the way this release did. The counts in the same table were re-measured while there: 29,392 lines across 122 modules, 2,359 tests across 205 files.

Closing test: tests/test_release_plan.py: test_the_release_plan_table_is_measured_not_remembered, which already existed and already failed correctly. The fix here is the procedure, not the guard.

Mutation: reverting the "Last tagged version" row to v2.10.0 fails the guard whenever a v2.11.0 tag exists.

D125 — Closed: every method on every generic Go type was invisible (High)

Go 1.18 added generic types, and docs/languages/go.md said a generic receiver "reports through its base name, Store". It reported nothing.

The receiver pattern required ) immediately after the type name, and func (s *Store[T]) Get puts [T] between them, so no method on any generic type was ever found. _mask_generics did not help — it blanks <…>, which Go does not use.

The suite covered the generic function form, test_a_generic_function_is_read_through_its_type_parameters, and never the receiver. So the page was the only thing asserting the behaviour, and the page was wrong — D123's shape, in code rather than prose.

The oracle could not have caught this. lizard does not find these methods either: on the fixture it returns nothing for genericMethod. A construct both implementations miss is invisible to a check built on their agreement, which is the limit stated for PHP match (D119) meeting a real defect rather than a hypothetical one.

A qualified receiver (func (s *pkg.Store)) is deliberately still not matched: Go requires a method's receiver base type to live in the same package, so that form does not compile and is not a gap.

Closing test: tests/test_go_declarations.py: test_a_generic_method_is_found_through_its_base_type, test_a_generic_method_with_two_parameters_is_found, test_a_value_receiver_generic_method_is_found.

Mutation: removing (?:\[[^\]]*\])? from _GO_METHOD_RE returns only the non-generic method from the fixture.

D126 — Closed: an assigned if closed the Ruby method at the wrong line (High)

Not an under-count. It reported a different function than the one in the file.

An opener counted only when its keyword led the stripped line, which correctly ignores the modifier form return 0 if x. It also ignored x = if cond, which is an expression in Ruby and does need its own end. The inner end therefore dropped depth to zero and the method was reported as finishing there:

def assign_if(cond)      # 8 lines
  x = if cond
        1
      else
        2
      end                # reported as the end of the method
  x + 1                  # outside the reported range
end

assign_if came back as 6 lines instead of 8, with its last two lines attributed to nothing. The Ruby page's own warning applies: a miscounted end shifts every range after it.

x = case v, x = begin and the @memo ||= begin memoisation idiom are the same shape and were equally wrong. The keyword must sit immediately after the assignment operator, which is what keeps the genuine modifier x = 1 if cond opening nothing — asserted beside the fix.

A guard ships beside the fix and is deliberately not cited as a closing test: the modifier form x = 1 if cond must keep opening nothing, and that behaviour was always right, so a test for it passes at the base. A closing test has to be one that fails without the fix, and naming a guard in that block would make the entry look better defended than it is.

lizard agrees at 2 once the construct is in the fixture, so this one the oracle would have caught. It was not in the fixture, because the fixture was written from the same knowledge as the scanner.

Closing test: tests/test_ruby_declarations.py: test_an_assigned_if_does_not_close_the_method_early, test_an_assigned_case_does_not_close_the_method_early, test_an_or_assigned_begin_does_not_close_the_method_early.

Mutation: removing _RB_ASSIGNED_OPENER_RE from _opens reports assign_if as 6 lines of 8.

D127 — Closed: PHP's Elvis operator fell through the hole the nullable fix opened (Medium)

$x ?: $y is PHP's short ternary and scored 0.

D115 made a ternary require a following : so that ?int nullable type hints stop counting. PHP spells its Elvis operator with exactly the two characters that rule excludes, so fixing one defect created another in the same expression. The fixture had $v > 0 ? 1 : 2 and ?? and not ?:.

Counting the long form and not the short one would make a score depend on which spelling a codebase prefers — the same argument already written down for PHP's word operators.

lizard does not count it either, so it is a declared divergence with the grammar reasoning, not a silent one.

Closing test: tests/test_php_declarations.py: test_the_elvis_operator_is_a_decision, which asserts the neighbours that must keep working — the long ternary, ??, and ?int — because one rule covers all four.

Mutation: removing \?: from PHP_COMPLEXITY_RE scores the fixture's elvisOperator 1 instead of 2.

D128 — Closed: Rust's let … else decided nothing (Medium)

Stable since Rust 1.65 and the idiomatic early return for a refutable pattern: the binding succeeds, or the else block diverges. Two paths, one decision — and no if token anywhere in it, so nothing in a keyword set was ever going to see it. The fixture had if let, while let and ?, but not let … else.

The fix carries its own trap, and the test pins it: if let … else and while let … else contain both a let and an else on one line, and their decision is already counted by the if/while. Counting the let … else shape there too would score them twice, so the pattern excludes it by lookbehind.

lizard does not count let … else, so this is a declared divergence.

Closing test: tests/test_rust_declarations.py: test_a_let_else_is_a_decision.

Mutation: removing the let … else alternative from RUST_COMPLEXITY_RE scores the fixture's let_else 1 instead of 2; removing the lookbehinds scores if let … else 2 instead of 1.

D129 — Closed: the grammar fixtures were a sample, and the page called them the specification (Medium)

docs/language-support.md said each fixture "exercises the control-flow constructs that language's specification defines". They exercise the constructs their author already knew about, written from the same knowledge as the scanners they check — which is the exact failure D119 was opened to end, surviving one layer up.

The evidence is D125–D128: four defects, and none of their constructs were in the fixtures. A generic Go receiver, an assigned Ruby if, PHP's Elvis operator, Rust's let … else. Agreement with an independent implementation on a sample the author chose is not coverage of a grammar, and the page claimed otherwise.

All four constructs are now in the fixtures. The claim on the page is narrowed to what is true: the fixtures exercise a set of constructs from each specification, the set grows when a gap is found, and the comparison says nothing about the constructs it does not contain.

D125 also fixes the ceiling on this method rather than the floor: lizard finds no generic Go method either, so no amount of fixture coverage would have surfaced that one through agreement alone.

Closing test: tests/test_grammar_constructs.py: test_the_python_fixture_covers_every_branching_node_in_the_grammar, which asks ast for the checklist instead of accepting one written from memory. The agreement test was cited here first and does not close this: with the code and the fixtures both reverted it passes, because a sample compared against itself always agrees — which is the defect.

Mutation: deleting the four added fixture functions restores a suite that passes while D125–D128 are all present.

D130 — Closed: --sarif-input is the third operator-named read D104 claimed did not exist (High)

D104 closed on the claim that --config and --baseline were the only two path-taking entry points that never went through any validation. They were not. --sarif-input is the same kind of operator-named path — repeatable, allowed outside --root, ingested into the published report — and it is still:

json.loads(Path(path).read_text(encoding="utf-8"))

in sarif.read_sarif_inputs, called from cli.py on args.sarif_input before build_report. No regular-file check, no size cap, no one handle, no O_NONBLOCK. A FIFO hangs. /dev/zero reads until memory dies. A directory or a missing file is an uncaught IsADirectoryError or FileNotFoundError. A symlink is followed.

That is the hang D104 measured on --config and then listed as closed. SECURITY.md names this flag. tests/test_sarif.py only parses a well-formed fixture. Sonar will not reopen D104: the rule keyed the two call sites that were patched.

The class, not the instance: every CLI argument that reads a file the operator named. Writes (--output, --write-baseline) go through write_artifact and are a different population. --conformance is a revspec, not a file.

Fixed by routing, not by new machinery. read_sarif_inputs calls read_operator_file, so --sarif-input gets the one handle, the O_NONBLOCK open, the regular-file check through that handle and the size cap that --config has had since D104. An OSError is re-raised as the door's own named refusal.

The test design is the part worth keeping. A directory already raised an exception on the shipped tree — an uncaught IsADirectoryError — so a test asserting "something was raised" passes against the defect; IsADirectoryError is an OSError. The assertion is the refusal contract: PathNotAllowed, naming the file. And the FIFO case runs in a child process with a deadline, because a hanging test cannot fail cleanly — in-process it stops the suite and the falsifier gate instead of reporting anything.

Closing test: tests/test_operator_named_reads.py: test_a_directory_named_as_sarif_input_is_refused_by_name, test_a_missing_sarif_input_is_refused_by_name, test_a_fifo_named_as_sarif_input_returns_rather_than_hanging, and test_a_well_formed_sarif_input_is_still_read so the guard does not cost the feature.

Mutation: restoring json.loads(Path(path).read_text(...)) in read_sarif_inputs makes the directory case an uncaught IsADirectoryError again and the FIFO case time out.

D131 — Closed: read_operator_file was not made the read primitive (Medium)

D104 built a one-handle reader and applied it to the two Sonar hits. Every other read is still exists / is_file / is_symlink and then read_text on the name — the sequence _safe_write and read_operator_file exist to forbid.

The always-on case is history. Every successful CLI or MCP audit reaches read_history:

mkdir -p .maintainability
mkfifo .maintainability/history.jsonl
maintainability-agent --root . --format json

history_path.exists() is true, so the run records; attach_history_views then read_texts the FIFO and blocks. Git cannot store a FIFO, so this is a local or agent-written tree, not a merged blob. D19 already treated a FIFO as a hang that must not be opened.

The same name-then-open sequence, same hang:

A committed multi-gigabyte history.jsonl is the git-shippable half: read_operator_file would refuse at 8 MiB; this path never calls it.

Eight sites, five modules, all routed. History (both the always-on read and the pre-write guard), first-run persistence, the XDG user tier, the MCP baseline clobber check, and _safe_write's own append and JSON-clobber reads — the last being a time-of-check/time-of-use gap in the helper written to close them.

repository_path is why the class hid. It bounds a path's location to the audited tree, which reads as "this path is safe". It says nothing about what kind of file is there, so an in-tree FIFO passes the bound and then blocks forever. Location and kind are different controls and the prose conflated them.

The primitive moved out of config. Routing the user tier through it made config -> _user_config -> config, which the acyclic test caught. The cycle was the symptom: a reader that everything reads through cannot live in a module that reads through it. _operator_reads is now its own foundation module and config re-exports both names, so all twelve existing importers are untouched.

One deliberate judgment, stated rather than silent: an unreadable user config still reads as absent. That tier is optional and always has meant "no user tier"; refusing to read a FIFO there is the fix, refusing to start would be a worse change, so PathNotAllowed joins the caught set at that one call site.

A test was patching the mechanism. test_unreadable_user_config_ reads_as_absent monkeypatched Path.read_text. The fixed code opens a handle and never calls it, so the fixture stopped denying anything and the test passed against a file it was supposed to be unable to read. It now denies by permission and skips as root.

Closing test: tests/test_operator_named_reads.py: test_a_fifo_where_the_history_goes_does_not_hang_the_read, test_an_ordinary_history_is_still_read, and test_no_door_reads_a_named_path_outside_the_primitive, which now parses all seven state-file modules rather than the two D130 needed — the class, not the instances.

Mutation: restoring path.read_text(...) in read_history — the always-on site, and not one the closing tests name individually — is reported by the AST guard as _scan_history.py:339: read_text().

D132 — Closed: a cognitive-only --check fail still prints a negative line overage (Medium)

An audit of --check found a short complex function rendering as -71 over because over_by was always lines - max_function_lines. The merge fixed the complexity-versus-length case and stopped hardcoding JSON scored: false. It did not name cognitive complexity as a budget.

_DECLARATION_BUDGETS is length and cyclomatic only. A function that fails max_cognitive_complexity while remaining under both of those produces an empty breach list, then the fallback:

over_by = metric.lines - fallback   # the length budget it is inside

Reproduction, current main / this branch: max_cognitive_complexity=1, a seven-line nest of ifs, max_function_lines=80, max_complexity=50. Result: over_by: -73, render ✗ nested:1 — -73 over. The finding is real; the figure is about a budget that did not fail.

The comment on _breaches_for says a figure has to be about the thing that failed or it is worse than no figure. The fallback reintroduces the thing the comment says was removed.

Reproduced at -72 over before fixing. Three parts, because naming cognitive alone would have been incomplete:

Closing test: tests/test_in_loop_budgets.py: test_a_cognitive_only_failure_does_not_report_a_negative_line_overage, which asserts both halves: no negative overage, and cognitive named in the breach list. Asserting only the first would pass on a fix that silently dropped the finding.

Mutation: removing ("cognitive", "cognitive", "max_cognitive_complexity") from _DECLARATION_BUDGETS sends the declaration back to the fallback and reports -72.

D133 — Closed: "a piped diff will say it could not parse" is a Python-only sentence (High)

Gemini found a unified diff piped to --check reading as clean. The fix taught _parses to refuse content ast.parse rejects. Brace languages never refuse: _parses returns True for every suffix except .py. Docs and the changelog still speak as if the class closed:

A diff piped into --check is not file content. It will say it could not parse; do not read that as clean.

.js, .java, .ts, .c are in DECLARATION_SUFFIXES, so declarations_read is true, note is empty, the scanner finds nothing or scores a fragment, and the process exits 0.

printf '%s' '--- a/x.js
+++ b/x.js
@@ -1,3 +1,4 @@
 function hello() {
+    console.log(1)
     return 1
 }
' | maintainability-agent --check src/thing.js --format json

Expect declarations_read: false and a parse note. On this tree: true, empty note, exit 0. Same shape Gemini found; only the language that uses ast.parse was taught to refuse.

The class is every suffix --check claims to parse, not ast.parse. Do not mark valid brace source "unparsed" because it minted zero declarations.

Fixed by detecting the mistake, not by parsing twelve languages. Unified diff is a specified format, so _is_unified_diff reads its shape — the ---/+++ header pair and a hunk header matching @@ -n[,m] +n[,m] @@ — before any per-language branch. That holds for every suffix equally instead of for the one with a parser in the standard library.

By shape, not by substring: a string literal containing @@ -1,3 +1,4 @@ is somebody writing about a diff. Mention versus assertion, which this project has now met in suppression markers (D108), the falsifier escape phrase (D111), the cross-repository guard and the risk patterns. Asserted by its own test.

The audit's warning is honoured rather than traded away: valid brace source is not marked unparsed. Zero declarations is not evidence of a parse failure — plenty of valid files mint none — so nothing beyond the diff format is claimed for languages with no parser, and the README now says that instead of implying content validation it does not do.

Closing test: tests/test_in_loop_budgets.py: test_a_piped_diff_is_refused_for_every_language_not_only_python, parametrized over the nine suffixes the Python-only fix missed; test_ordinary_source_is_not_called_a_diff over the same nine; and test_a_line_that_merely_mentions_a_hunk_header_is_not_a_diff.

Mutation: restoring if Path(path).suffix != ".py": return True above the diff check reports declarations_read: true and an empty note for a diff named .js, .java, .go or any of the other seven.

D134 — Closed: --check accepts flags it would have to ignore (Medium)

--staged named the class: a flag accepted and ignored teaches the caller it was honoured. _STAGED_REFUSES includes --comment-output, --attestation-output, --agent-instructions-output, --hostile-prompt-output, --transformation. _CHECK_REFUSES does not. _check_action returns before write_outputs, so --check --comment-output x.md exits 0 or 1 and writes nothing. The --staged sibling is tested; this door is not.

Derived, not extended. _CHECK_REFUSES is now {**_STAGED_REFUSES, "staged": "--staged"}. Both doors write nothing, run nothing and apply no repository gates, so a flag one would have to ignore the other would too — and adding the five missing entries by hand would have left two lists to keep in step, which is what produced the drift. A flag added to _STAGED_REFUSES tomorrow covers --check on the same commit: the drift is impossible rather than merely detected.

Closing test: tests/test_in_loop_budgets.py: test_check_refuses_every_flag_staged_refuses, parametrized over the five that drifted, and test_the_two_doors_refuse_the_same_flags for the relationship itself.

Mutation: rebuilding _CHECK_REFUSES as its own dictionary missing any one entry — --comment-output, say — fails the structural test naming comment_output.

D135 — Closed: --check headroom is the line budget only (Medium)

The product claim is remaining budget, not only a verdict. _headroom and _band use the line limit. A 20-line function at cyclomatic 12 (warn_complexity 10) is status=warn, band=ok, silent in text, and JSON headroom looks fine. Cognitive warn is the same. Complexity is a budget they fail on (D132) and never show remaining for.

Headroom is driven off _DECLARATION_BUDGETS, the same list _breaches_for uses, so the budgets a declaration can fail on are exactly the budgets it can show a remainder for. They were two different sets, which is how complexity could fail a function while never showing how close it was. D132 completed that list by adding cognitive, and both paths read from it now — the next budget added lands in both at once.

Two decisions worth stating:

Closing test: tests/test_in_loop_budgets.py: test_headroom_covers_complexity_not_only_lines, with test_headroom_still_reports_the_line_budget as the guard that the line remainder kept its meaning.

Mutation: banding on metric.lines alone again reports band: ok for a function on the cyclomatic warn line.

D136 — Closed by decision: --check stays on the CLI for now (Medium)

Product intent: chat is the primary surface; a capability that exists only behind a terminal prompt does not exist for most users. 2.10.0 closed "the in-loop half" and the same page says --check is CLI-only. MCP still has audit_repository and get_agent_info. The skill shells out to the CLI. An agent that cannot spawn a process cannot ask the question this release exists for.

The red contract for an MCP check_content tool is on test/mcp-check. This entry is the ledger for that gap, not a second implementation queue.

Decided 2026-09-06: keep it in the CLI. Not worth MCP for now. Recorded as a non-goal rather than left open, because an open entry naming a gap is an instruction to close it, and the next audit would re-derive this one from the same product-intent sentence that raised it.

The gap the entry describes is real and is not disputed: chat is the primary surface, --check is the in-loop feature, and an agent that cannot spawn a process cannot ask the question. What was weighed against it is that an MCP tool is not a port of a flag — it is a tool name, an argument contract, a decision about whether it joins the setup and consent flow every other MCP call goes through, and a payload shape to support afterwards. That is a product increment, not a defect fix, and it is not the increment this release is for.

This does not close the question. It closes the entry, so the ledger says what was decided instead of implying nobody looked. If chat users ask for it, this is reopened as a feature with its own contract — not resumed as an unfinished bug.

The red contract on test/mcp-check stands as the starting point for that work if it is taken up.

Mutation: none — there is no behaviour to defend. A test asserting the MCP tool is absent would pin a decision rather than a property, and would have to be deleted by the change that implements it.

D137 — Closed: one Python file let a C repository skip a whole criterion (High)

The declarations dimension may be driven by analyzer readings only when the criterion set is complete — cyclomatic complexity, declaration lines and cognitive complexity. _declaration_pressure checks it:

covered = {concept for values in per_unit.values() for concept in values}
if declaration_concepts_missing(covered):
    return dict.fromkeys(ANALYZER_DIMENSIONS)

per_unit spans every declaration in the repository, so covered is a union across all languages and all tools. The check is written at repository scope and applied to units that are scored individually.

The reason it matters is which tools supply which concept. Enumerated from the adapters rather than from prose:

adapter emits languages
lizard cyclomatic_complexity, declaration_lines 16 of the parsed set
pmd cyclomatic_complexity, cognitive_complexity apex, java, javascript, scala, xml
complexipy cognitive_complexity python

So for C, C++, C#, Fortran, Go, Rust, PHP, Ruby, Swift and TypeScript there is no cognitive source at all. A repository in any of them should fall back to the built-in tier. It does not, if it contains any Python that complexipy can read — a setup.py, a build script, one test helper.

Reproduced, 50 C declarations lizard measured for cyclomatic complexity and lines, thresholds warn/max 8/15 and 40/60:

A) the C declarations alone           -> {'declarations': None}
B) the same C, plus one setup.py with
   a single function, cognitive 0     -> {'declarations': 0.2451}

Nothing about the C changed. One trivial Python function flipped the whole repository from "unmeasured, use the built-ins" to a confident analyzer rate, computed over 50 declarations whose cognitive complexity was never read. The 0.2451 is the C units sitting in the MILD cyclomatic band, averaged with the one clean Python unit: (50 × 0.25 + 0) / 51.

This is the failure the code's own comment describes, at a scope it does not check:

a reading that only ever saw complexity cannot produce a rate comparable to it, because every long-but-simple function passes by not having been measured

It is not a corner case. 47 of the 112 corpus repositories satisfied the gate in the 2026-09-07 re-measure, and the list includes FFmpeg, git, bitcoin, jq, redis and imgui (C/C++), cp2k, elmerfem, fds, specfem3d and xtb (Fortran), and PowerToys, aspnetcore and terminal (C#) — none of which has a cognitive source for its primary language, and every one of which carries complexipy in its tool list.

Consequences for the anchor. declarations is one of five scored dimensions at weight 1.0, so this moves grades. It also means the corpus median for declarations is partly built from readings this entry says are not comparable, which puts the fix inside the recalibration release rather than after it: correcting the scope changes which repositories use analyzer readings, which moves the reference, which is exactly what that release is for.

Relationship to the planned per-concept merge. The roadmap schedules "merge analyzer evidence per concept rather than per dimension" and justifies it with the claim that lizard "can never satisfy the gate for Go, Rust, PHP, Ruby, Swift, C, C++, C#, Fortran, COBOL or Python". That list is wrong about Python — complexipy supplies cognitive complexity for it, which is why the Python corpus members legitimately satisfy the gate, as _calibration.py already recorded. It also omits TypeScript, which genuinely has no cognitive source. The item stands; its stated reason needs correcting before it is built against.

Fixed by checking the criterion set per language. _declaration_pressure buckets units by the language of the file they were read from and requires each bucket to carry all three criteria. declined_dimensions was corrected the same way and now names the languages that forced the fallback, which is the half a reader sees.

Two things the fix deliberately does not do, stated so neither is mistaken for an oversight:

This is the fifth fix to this bridge, so the fifth fix is not the deliverable. test_analyzer_bridge records the previous four — a ratio from a sample of one, a Python-only concept mixed with a multi-language one, two formulas wearing one name, and complexity counted against three criteria — and its docstring says its tests exist "so the fifth number means something". The fifth arrived anyway. D137 is a repeat of the second, in a different costume.

The reason those tests missed it is structural, not careless: every fixture in that file builds measurements for a single language, and this defect only appears when two languages are present and a tool covers one of them. The shape was never in the sample — which is this register's own diagnosis of thirty of its entries, applied to the tests written to prevent them.

So the class is closed with sweeps rather than another scenario, and the two sweeps do different jobs — a distinction the falsifier gate had to point out.

test_no_other_language_can_supply_a_missing_criterion falsifies. It sweeps the lending mechanism across every language pair and every withheld criterion, and fails at the base, because the defect needs two languages to appear at all.

test_every_language_is_scored_only_on_a_complete_criterion_set guards. It enumerates every language in DECLARATION_SUFFIXES against every subset of the three criteria — generated, not written — and asserts the sentence the bridge exists to keep true: a declaration may be scored only when its own language carries all three. It passes at the base, and that is correct rather than a weakness: for a repository of one language the union check and the per-language check agree. It is therefore declared as covering existing behaviour and is not cited below.

What the guard defends is the next variant, demonstrated rather than asserted: injecting a single-language exemption (language != 'Swift') into the completeness check passes all three instance tests cited below and fails six cases in the sweep. A sixth variant of this defect cannot arrive by being a language or tool split nobody wrote a fixture for.

Closing test: test_one_python_file_does_not_buy_the_criterion_for_c, test_the_fallback_note_names_the_language_that_caused_it, test_an_incomplete_language_is_named_even_beside_a_complete_one, test_no_other_language_can_supply_a_missing_criterion in tests/test_declaration_criteria_scope.py.

Mutation: the member broken is a C declaration — _unit_languages made to return Python for decode.c — not the setup.py unit the reproduction names. That sits outside what the closing tests enumerate for a reason worth stating: the reproduction's Python helper is the member the entry was written around, so breaking it confirms the sample and says nothing about the claim. The claim is about the C declarations, and only mutating one of those asks whether the per-language bucket is real. It fails test_one_python_file_does_not_buy_the_criterion_for_c, which is the test that would otherwise pass for the wrong reason.

D138 — Closed: a hunk-only diff read as clean (High)

D133 closed on the claim that a piped diff is refused by its format in every language. The detector requires three things: a --- line, a +++ line, and a hunk header. That is a full unified diff, as diff -u and git emit it.

An agent at this door does not always paste that. The ordinary paste is the hunk:

@@ -1,3 +1,4 @@
 function hello() {
+    console.log(1)
     return 1
 }

On a918e2b, _is_unified_diff is false, _parses('x.js', …) is true, check_content returns declarations_read: true and an empty note. Exit 0, nothing to say, "clean". Same shape Gemini found; D133 taught the door to recognise the envelope and not the letter.

Python still refuses this via ast.parse. Brace languages do not. The class D133 named was "a piped diff", not "a piped diff that still has its ---/+++ headers".

Fixed by refusing either half of the shape. _is_unified_diff returned has_hunk and has_old and has_new; it returns has_hunk or (has_old and has_new). Either fragment is a diff, and the hunk-only one is the commoner paste because a chat window shows the hunk and not the file headers.

The mention-versus-assertion guard needed nothing, which is why this could be loosened at all: _HUNK_HEADER is applied with match, so a hunk header quoted inside a string — "paste a hunk like @@ -1,3 +1,4 @@ here" — is not at the start of its line and still parses. Somebody writing about a diff is unaffected.

A guard sits beside the falsifier and is deliberately not cited below, because naming it here would cite it as one: brace source already parsed at the base, so it defends the refusal from widening into "refuse everything" rather than proving the refusal. Its name is in tests/test_in_loop_check.py next to the falsifier, and it carries the covers-existing-behaviour phrase.

Closing test: test_a_hunk_without_diff_headers_is_unread_for_every_declaration_suffix in tests/test_in_loop_check.py.

Mutation: the member broken is has_hunk or ... restored to has_hunk and ..., which is the exact conjunction D133 shipped. It sits outside what the closing test enumerates because the test's population is DECLARATION_SUFFIXES — it names no suffix and no operator, so the mutation cannot be the sample it was written from. It fails all 46 suffix cases; the brace-source guard beside it stays green, which is what proves the refusal did not simply become "refuse everything".

D139 — Closed: the language-support lead said every branch set is derived from its grammar (Medium)

D129 closed by narrowing the verification section: Python's coverage is derived from ast; every other language's fixture is a set; lizard agreement says nothing about constructs the fixture does not contain; Ripper / go/ast / syn "is unwritten, not refused."

The page's lead, same file, still says:

Each language's branch set is derived from its own grammar, and every one of them is checked construct-by-construct against an independent implementation.

That sentence is the defect D129 named. The honest paragraph is seventy lines later. A reader of the table never reaches it. #197 added a lizard-token sweep, which is a second implementation's keyword list, not a grammar enumeration, and the test's own docstring says so. The standard (2026-09-06) asks for the language's specification. The lead claims that work is done.

Fixed where the reader arrives, not where the audit reported it. The lead now says what the verification section says: Python's branch set is derived from its own grammar because ast implements it and CI enumerates from it, and every other language's is a set somebody wrote down. It also names what 2.11.1's sweep actually is — a second implementation's keyword list, better than the author's memory and still not a grammar — because calling that a grammar enumeration would reopen this entry with extra steps.

The correction goes in the lead because that is the defect: D129 corrected the section seventy lines later and a reader of the table never reaches it. A correction applied where it was reported rather than where it is read is the shape this register keeps finding.

A guard sits beside the falsifiers and is deliberately not cited below, because naming it here would cite it as one. The honest paragraph shipped with D129, so a check that it is still present passes at the base — it defends the fix from being satisfied by silence rather than proving it. Its name is in tests/test_language_support_honesty.py and it carries the covers-existing-behaviour phrase.

Closing test: test_the_lead_does_not_claim_every_language_is_derived_from_its_grammar and test_the_lizard_sweep_is_not_described_as_a_grammar in tests/test_language_support_honesty.py.

Mutation: two, because one alone is satisfiable the wrong way and the entry said so. Restoring the lead's "each language's branch set is derived from its own grammar" fails the first test. Deleting the honest paragraph — the cheapest way to pass a check that only forbids a sentence — fails the second, which requires the distinction to be stated rather than merely absent. Both were run and both failed as described. The members sit outside what any single test names because the page can break in either direction, over-claiming or falling silent, and a check written against only the reported direction would have shipped the other.

D140 — Closed: a published declarations rate mixed two counting conventions silently (High)

#198 completes an analyzer criterion set by copying built-in cognitive complexity onto lizard's cyclomatic and length for the same (path, start_line). The result is published as analyzer_pressures. ADR 006's recorded reason for not writing more scanners: lizard, radon and mccabe all report "cyclomatic complexity" and disagree on the same function; a single number measures that tool's counting convention, not the code. Mixing lizard's convention with this project's cognitive regex on one unit is a third convention. It is not corroboration.

The report records the mix only as "completion from the built-in tier included, which is the point of publishing it." A consumer of analyzer_pressures.declarations cannot see which criteria were filled, from which instrument, for which unit. 3.0.0 re-grades on this number.

This is the scoring claim for the release that is about to publish. It is not silent in the code comments. It is silent in the artifact a user reads.

Fixed by naming the fill in the published label. analyzer_scored now reports declarations (completed by built-in detectors) when the analyzer measurements alone did not cover the criterion set. The fill is derived from the raw measurements the pressures were reduced from, not from a flag set alongside them, so the label cannot disagree with what was actually published.

ADR 006 is unchanged and this is it working: the analyzer tier stays primary for what it measures, and the label stops crediting lizard for the scanner's cognitive complexity. lizard reports cognitive complexity for no language at all, so on thirteen of the fourteen parsed languages the fill is the normal case rather than the exception.

Closing test: test_a_completed_go_pressure_names_its_builtin_fill_when_published in tests/test_criteria_completion.py.

Mutation: the member broken is _completed_from_the_built_in_tier made to return an empty set, which restores the unprovenanced label while leaving the number identical. It sits outside what the closing test enumerates because that test asserts the label, and the mutation changes only the label — every pressure value, every dimension and the estimate stay byte-identical, so a test comparing numbers would pass. That is the point: the defect was never a wrong number, it was a right number with a wrong attribution.

D141 — Closed: scan reads hung on a FIFO, and skipped it silently (Medium)

D131 routed eight state-file sites through read_operator_file. The closing AST walk is STATE_FILE_MODULES, a seven-name list. source.py and metrics.py still path.read_text the files the scan discovers.

mkfifo src/hang.py
maintainability-agent --root . --format json

repository_path is not in this path; occupancy for writes is not either. The scan opens the FIFO and blocks. Git cannot store a FIFO, so this is a local or agent-written tree — the same population D19 and D131 already treated as a hang that must not be opened.

The class D131 named was every read_text of a path that is not the primitive. The close carved "state files" out of that sentence without recording the remainder as a limit.

Fixed in two places, and the second was not the reported one. The regular-file guard is extracted as open_regular_file, so read_operator_file and the new read_source_file share one implementation rather than the scan growing a second. source._read and metrics.read_lines both go through it.

That alone changed nothing, because the FIFO never reached a read. iter_files tested path.is_file() first, which is False for a FIFO, so src/hang.py was dropped from the walk and the audit measured one file short without saying so. Skipping and blocking are the two ways this goes wrong and both are wrong: the primitive refuses rather than waiting, and the walk now refuses rather than pretending the file was not there. Directories are excluded before the check and a symlink leaving the tree is still dropped by within, so what reaches it is a path claiming a source extension that is not a file.

Closing test: test_a_fifo_discovered_by_the_source_scan_is_refused_without_hanging in tests/test_operator_named_reads.py.

Mutation: the member broken is the walk, not the reader — iter_files restored to testing is_file() first, which silently skips the FIFO again. It sits outside what the closing test enumerates because that test names a read primitive and a PathNotAllowed, and this mutation touches neither: every read site still goes through read_source_file, the primitive still refuses a FIFO handed to it, and STATE_FILE_MODULES is untouched. The audit simply never offers it one. A mutation of the reader would have been the sample the entry was written from; this is the half the entry did not predict.

D142 — Closed: the prescribed remedy could not fix the reported problem (High)

Found by running the audit against this repository through the MCP server, the surface product intent calls primary. Nine analyzers came back "not installed or not on PATH" — the whole Python pool, lizard and complexipy among them. declarations fell to the built-in tier and the report said so honestly.

Every one of the nine was installed. locate searched the interpreter's own bin and then PATH. Its docstring assumed "Python analyzers install alongside the agent", which holds in a virtualenv and fails on a system Python: the framework directory is not writable, pip falls back to --user, and the console script lands in the per-user scripts directory — here ~/Library/Python/3.11/bin, on neither list and not on the default PATH.

So the environment work order told the operator to run pip install lizard; they had; and the next audit reported it missing again. The remedy the product prescribes cannot fix the problem the product reports, for any operator on a system Python, which is the default install path the README itself offers.

The silent fallback scored higher than the truth. Measured on this repository, same commit, same config:

pool invisible pool visible
tools contributed 0 of 13 9 of 13
verified grade B C

A degradation that flatters the score is the worst direction for this failure: nothing in the result asks to be looked at. The gate passed, the grade improved, and the only signal was a work order whose instruction had already been followed.

Fixed by deriving both script directories from this interpreter — sys.executable's parent, then sysconfig.get_path("scripts", ...) under the preferred user scheme — rather than hardcoding either. PATH remains the last resort, so homebrew and system-package installs (jscpd, pmd) still resolve.

Closing test: test_a_tool_in_the_user_scripts_directory_is_found in tests/test_tool_resolution.py.

Mutation: the member broken is _agent_script_dirs reduced to its first entry, which restores the exact lookup that shipped. It sits outside what the closing test enumerates because that test names a directory and a tool, while the four guards beside it name the three orderings the fix must preserve — own-bin precedence, PATH as last resort, and absence still reported as absence. A fix that widened the search until something was always found would pass the falsifier and fail those, which is why they are written from the other direction: the danger here is a false positive naming a tool that cannot run.

D143 — Closed: the unanchored caveat named five anchored languages (High)

UNANCHORED_LANGUAGES read ("Swift", "COBOL", "Go", "Rust", "PHP", "Ruby"). 3.0.0 anchored five of those six: the corpus grew from 112 repositories to 180 and from eight measured languages to thirteen, adding Swift, Go, Rust, PHP and Ruby. The constant did not move.

Population. Every skin that prints a grade reads this constant — corpus_note in the reference block, the Markdown summary table, the HTML executive strip and the remediation prompt. So a Swift, Go, Rust, PHP or Ruby reader was told for two releases that their grade was provisional against a corpus containing none of their language, when 14 Swift repositories, 14 Go, 14 Rust, 14 PHP and 12 Ruby had been measured for it.

A caveat that names an anchored language is not a caution. It is a false statement about the evidence, and it undersells the recalibration that was the whole point of 3.0.0.

Reproduced, deriving the set from its two sources rather than reading the constant:

parsed (DECLARATION_SUFFIXES)  14 languages
corpus (corpus.json)           13 languages
parsed - corpus                ['cobol']
UNANCHORED_LANGUAGES           ('Swift','COBOL','Go','Rust','PHP','Ruby')

Fixed in the constant alone. Every skin already derived from it, so no presentation was special-cased — the three skin assertions in the closing test passed on the constant change with no renderer touched, which is the evidence that the disclosure had one source and it was simply wrong.

COBOL stays and stays alone. It is unanchored permanently rather than pending a re-measure: GitHub holds one COBOL repository above 500 stars and what is public is curriculum, Java parsers and JavaScript wrappers rather than COBOL codebases. Emptying the tuple would delete a disclosure that is still true. docs/standard.md and its packaged copy said "six parsed languages are not in it" and now say what the constant says.

The same claim on two other pages, folded in rather than ticketed. The constant is one source, but the sentence had been retyped into prose that no check reads:

Neither is a separate defect. They are the D143 claim on pages the fix's population did not reach, and closing D143 with them standing would be the instance-not-class substitution this register's falsifier standard exists to name. Nothing tests them, which is why they went stale two releases ago and why the constant's own test could not have caught it.

docs/language-support.md is deliberately not in that list. Its lead says every non-Python branch set is "checked construct-by-construct against an independent implementation" and then tells the reader the section below does not contradict it — which sends them past the one paragraph naming COBOL as the reader with no second implementation, since lizard does not read COBOL and there is no constructs.cbl in tests/fixtures/grammar/. That is a true gap and a different class: D139's, about what verification can promise, not about which languages the corpus anchors. It stays out of this entry rather than being absorbed into it, because folding an adjacent defect into a close is how a class gets counted as handled without being examined.

COBOL remains the permanent exception here, and UNANCHORED_LANGUAGES keeps naming it. Emptying the tuple would delete a disclosure that is still true.

Closing test: test_unanchored_languages_are_exactly_parsed_minus_corpus, test_corpus_note_only_calls_unanchored_languages_absent and test_every_grade_skin_prints_the_derived_unanchored_caveat in tests/test_unanchored_set_matches_corpus.py.

Mutation: the member broken is a language added to the corpus — corpus.json gaining a COBOL row while the constant still names COBOL — not a language removed from the constant. It sits outside what the closing test enumerates because that test names no language at all: both sides are derived, so the mutation is in the direction the old check could not see. The stale constant grew when a scanner shipped and never shrank when the corpus caught up, and a test written from the reported instance would have pinned "Swift is not unanchored" and missed the next one.

D144 — Closed: the environment remedy addressed whichever pip PATH found (High)

environment_work_order emitted pip install lizard and lizard --version. Neither names the interpreter that just reported the tool missing: both resolve through PATH, which on any machine with more than one Python is a different environment than the one the next audit reads.

Population. Every implemented adapter whose install is pip-backed — the whole Python pool, ten of the thirteen wired analyzers. Node and JVM overrides (npx, brew) are correctly not interpreter-scoped and are untouched.

This is D142 seen from the other side, and that is what makes it High. D142 was the agent failing to find tools the operator had already installed, because pip had put them where PATH did not look. The remedy this work order hands back sends the operator around that same loop — and the second time through, it looks like the product's own instruction simply does not work. On the machine where D142 was found, the work order said pip install lizard to an operator who had run exactly that.

Fixed at the emit site: pip-backed adapters emit {sys.executable} -m pip install <distribution> and verify with {sys.executable} -m pip show <distribution>. The _INSTALL values stay spelled as distribution names, because two tests derive the CI install list from them and moving that fact into the data would break the derivation.

Verification moved from --version to pip show, against the old comment's stated reason — that --version runs the same probe the runner runs. Since D142 the runner does not probe PATH at all; it searches this interpreter's script directories. A bare {slug} --version therefore verified a different thing than the audit does, and pip show asks the question that decides the next run.

Closing test: test_pip_adapter_work_orders_bind_install_and_verify_to_this_agent in tests/test_work_order_binds_to_this_interpreter.py.

Mutation: the member broken is the fallback, not an override — _remedy_for returning f"pip install {slug}" for an adapter absent from _INSTALL, which is the path nine of the ten pip-backed tools take. It sits outside what the closing test enumerates because that test names no slug: it walks the catalog, so a newly added adapter is covered on the day it is added rather than when somebody remembers to list it. Mutating fortitude's override instead would have been the sample the entry was written from, and would have left the fallback — the actual population — unproven.

D145 — Closed: repository-controlled reads bypassed the regular-file door (High)

D131 built one door for reading a path this tool was told to read, and D141 split open_regular_file out of it so a discovered path could use the same check. Seven readers never went through either. Every one of them reads a path the audited tree chooses.

Population, enumerated from the modules rather than from the report:

Reader What the tree controls
_discovery._generated_directories every package.json under the root
_discovery._vendored_directories .gitmodules at the root
_practice._read every CI and toolchain file it finds
_banner.banner_says_generated every discovered source file
_semantic_ts.recorded_type_analysis the checked-in analysis recording
_semantic_ts type-role sweep every TypeScript source
_test_commands._read every manifest it consults
_test_execution._coverage_from_this_run coverage.xml after the suite
metrics.unread_source the whole walk, by is_file()

Two failure modes, and the second is the one that keeps coming back. read_text on a FIFO waits for a writer that never comes, so a repository holding package.json as a pipe stopped the audit dead — and no except OSError catches that, because there is no error to catch, only a process that does not return. The other is quieter: metrics.unread_source and _semantic_ts.recorded_type_analysis tested is_file() first, which is False for a FIFO, and treated the path as absent. That is exactly the half of D141 that was left behind: iter_files was taught to refuse rather than skip, and the sibling walk in the same module was not.

unread_source is the worst of the set, because it is the function that reports how much of the repository the score describes. A source-suffixed FIFO went missing from both sides of that fraction — not read, not reported unread — so the coverage share read exactly as it would if the file had never existed.

Reproduced on the fix, by naming the population rather than deriving it from the presence of the defect:

package.json as a FIFO   → refused: … is not a regular file
.gitmodules as a FIFO    → refused: … is not a regular file
hang.py as a FIFO        → refused: … has a source extension but is not
                            a regular file

All three return in well under a second. Before the change the first two never returned at all.

Fixed. Every reader above now goes through read_source_file or open_regular_file; the bounded readers (_practice, _test_commands, _banner) keep their limit by reading through the checked handle rather than by name, so the head-only reads stay cheap. _discovery re-raises PathNotAllowed explicitly before its except (OSError, ValueError, AttributeError) clause, because PathNotAllowed is a ValueError and would otherwise be swallowed as malformed JSON — a refused path reported as bad content is the same class of lie this entry is about. metrics.unread_source refuses the way iter_files refuses.

The staged falsifier had to be rewritten before this could close, and that is worth recording rather than tidying away. tests/test_tree_reads_use_the_primitive.py as delivered could not pass or even collect:

The rewrite separates the two questions the old file conflated. Behaviour is asked on real FIFOs through the public entry points, and cannot be satisfied by rearranging code. Structure is asked over TREE_READERS, a written list of the readers that take a path from the audited tree, each checked to resolve so a rename fails loudly instead of emptying the sweep. The FIFO cases derive from the filenames _discovery mentions, which the fix does not delete — that is the correction to the self-emptying population.

"Repository-controlled" is a fact about where a path came from, and no AST walk can see it. So the list is written and its members are verified, rather than inferred and silently wrong.

Two of the five tests in that file are non-vacuity guards, not falsifiers: one asserts the reader list resolves, the other that the metadata names still match the module. Both pass at the base by design — that is what a non-vacuity clause is — so they are described here and deliberately kept out of the citation below.

Closing test: test_a_tree_reader_never_reads_a_path_by_name, test_metadata_fifo_refuses_and_does_not_block, test_a_source_suffixed_fifo_is_refused_by_the_unread_walk in tests/test_tree_reads_use_the_primitive.py.

The falsifier was written by the author of the fix, and that is recorded, not hidden. A fix should not be proved by the person who just convinced themselves they understood it. The delivered contract could not run and was blocking every other entry in this slice, so this falsifier is owed an adversarial read for that reason and not merely as routine. Mutation: the member broken is _practice._read — reverted alone to path.read_text while every other reader stays fixed. It sits outside the sample because the FIFO probes exercise _discovery and metrics.unread_source, and never reach _practice at all; only the structural sweep over TREE_READERS catches it. Breaking _discovery instead would have been the sample the entry was written from.

D146 — Closed: --check read a plus-only fragment as file content (High)

check_content refuses a unified diff, and D138 widened that to a hunk-only paste. This is the paste one step smaller again and the commonest of the three: an agent copies the green lines out of a review pane and pipes them in. No @@, no ---/+++, so every shape check passed it through, the brace scanners found nothing in +function hello() {, and declarations_read came back true.

Population. Every suffix in DECLARATION_SUFFIXES — 33 of them — because the refusal is a property of the content's shape and not of any one language's parser. The contract parametrizes over the constant, so a suffix added tomorrow is covered on the day it is added.

That matters here specifically: D133 closed this door for Python only, because Python was the language with a parser in the standard library that happened to refuse a diff. Every widening since has been a widening of the shape rule, and each one was written from the example that was reported. Stating the rule instead — a body whose every non-blank line begins with + or - is a diff fragment — is what makes the next fragment shape covered without a fourth entry.

The mention-versus-assertion guard survives by construction rather than by luck. Somebody writing about a diff writes prose or code around the quoted line — text = '+function hello()' — and that surrounding line does not begin with a mark, so the body is not all-marked and parses normally. A mark has to hold column one of every line to count, which is the same guard _HUNK_HEADER's anchored match gives the hunk check. Context lines carry a space rather than a mark, so a pasted excerpt of a hunk's context is still read as the file's own text, which is the ordinary case and stays working.

The second test in that file is a guard, not a falsifier. It passes at the base by design, because its job is to prove the widening did not cost the mention-versus-assertion behaviour that already worked, so it is described here and deliberately left out of the citation below.

Closing test: test_plus_only_fragment_is_not_read_as_source in tests/test_check_refuses_plus_only_fragments.py, parametrized over DECLARATION_SUFFIXES.

Mutation: the member broken is .html, a suffix neither the fragment example nor the guard example names — +<div> and +</div> as a two-line body. It sits outside the sample because the reported instance was a brace-language function body, and a fix written from it could have keyed on {/} and still passed every example in the entry. The parametrization is over DECLARATION_SUFFIXES, so the test names no suffix at all and the mutation has to come from the constant.

D147 — Closed: the audited tree could persist host execution authority (High)

D35 established that a user enables analyzers.acquire_tools, and acquisition_permitted reads the XDG user tier alone so a repository cannot grant itself npx --yes. Two staged setup writers handed the repository's own document to write_user_answers.

Population. Every call site of write_user_answers — three: _persist_answers, which correctly writes the setup payload, and _apply_bounds and _apply_command, which wrote stored: the repository's maintainability-agent.json, read back and passed through. So a repository containing

"analyzers": {"acquire_tools": true},
"test_execution": {"requested": true},
"expected_commands": {"test": ["…"]}

plus any stage-two reply — the labor rates, or the test command — copied all three into the user tier. acquisition_permitted then read its own tier and answered true. The check was never wrong; it had been told the tree's answers were the person's.

The mechanism is worth naming because it is not a missed validation. Both writers built one dict for the repository write and reused it for the user write, and at the moment they were written the two documents held the same fields. The tier boundary was carried by a variable name.

A second half, on the seam that runs commands rather than installing them. suite_opted_in read test_execution.requested and expected_commands.test from the merged config, where a repository beats a person by design. So four lines in a pull request opted the host in and chose the program, with no setup reply needed at all. Decision 9's guarantee is that no opt-in means no spawn; the tree was writing its own opt-in. The request now comes from the user tier: load_config strips test_execution.requested out of the repository document before the merge.

Correction, 2026-09-11 — this entry closed on a claim that was not true when it was written. The paragraph above read: "Both halves now come from the user tier through opted_in_command, the command as well as the request." opted_in_command did not exist. It was named here, and in two more docstrings, as the reader that made keeping expected_commands.test in the repository document safe — and nothing implemented it. run_test_suite went on reading the merged config, so a repository that documented a command still had that command executed the moment any person opted in.

Half of this entry's own subject therefore survived its close for two days, described as fixed. The remainder is D152, which implements the function and moves every spawn setting — command, child environment and timeout — to the person's tier.

Recorded here rather than by reopening, because the class is closed now and a reader arriving at D147 needs to know its original account was wrong. Left uncorrected, this entry is evidence that the boundary held, which is the worst thing a defect register can contain. Same shape as D47, which had to be corrected after it was written, and it is why the falsifier standard exists: a citation naming a function is indistinguishable from a function.

pylint stays selected, and this is the one place the audit prompt asked for a change that the measurement does not support. The prompt directed that pylint be refused by selection the way eslint was in D39, unless Decision 9 were amended. Decision 9 does not need amending, because pylint as shipped does not execute the tree's configuration. Measured on pylint 4.0.8 against the exact scenario:

.pylintrc in the tree shipped --rcfile=/dev/null init-hook
[MAIN] init-hook=… present did not fire
[MAIN] init-hook=… absent (control) fired
[MASTER] init-hook=… absent (control) fired
[tool.pylint.main] in pyproject.toml present did not fire

The D39 flag holds. Refusing pylint by selection would drop the style and structure concerns for Python and buy nothing, and this project's rule is that a capability is removed on a proven gap rather than a suspected one. Nothing was written into SECURITY.md in place of a fix.

The staged contract for it — test_selected_pylint_does_not_import_a_module_from_the_tree — passes, but vacuously: its payload is init-hook=import poison, and import poison raises ModuleNotFoundError even with no isolation at all, because the audited tree's directory is not on sys.path when the hook runs. Its control does not demonstrate the attack, so its pass says nothing. Recorded here rather than fixed, because this cycle may not write tests/; the working payload is in the table above.

Two of the five tests in that file are guards and are deliberately left out of the citation below, because both pass at the base by design. The first asserts the write_user_answers sweep is not empty, which is the falsifier standard's own non-vacuity clause rather than a defence of this fix. The second asserts the pylint property --rcfile=/dev/null already held — the measurement recorded above, and the reason pylint stays selected.

Closing test: test_bounds_only_reply_cannot_copy_repo_authority_to_user_tier, test_command_only_reply_cannot_copy_repo_acquisition_to_user_tier, and test_repo_test_command_cannot_opt_in_a_user_who_said_no in tests/test_tree_cannot_opt_the_host_in.py.

Mutation: the member broken is _apply_bounds, and the mutation is not "put the repository document back". It is the narrower one a fix written from the report would survive: keep _user_tier_with but seed it from _read_config instead of user_config_answers, so the merge is right and the source is wrong. That sits outside the sample because the closing tests name neither writer — they sweep write_user_answers call sites and then exercise both staged replies through apply_answers, so a third writer added tomorrow is covered and a wrong source fails the same way a wrong shape does.

D148 — Closed: four sentences said "COBOL are parsed" (Low)

D143 corrected UNANCHORED_LANGUAGES to ("COBOL",). Four sentences built from that constant kept the plural they were written with: the corpus note inside the score object, the Markdown caveat, the HTML strip beside the letter, and unanchored_sentence itself. Every skin that grades a COBOL repository printed "COBOL are parsed but are not in the reference corpus, so a grade or multiple reported for code in them is provisional".

Low severity and filed anyway, because the mechanism is the one this register exists for and the cost of not filing it was demonstrated: it was reported twice as a known wart and fixed neither time.

Population. Four call sites, and a contract test that asserted the literal f"{expected_names} are parsed" — so the wrong grammar was pinned by a check rather than caught by one. That is the sharpest part of this entry. A test written from the sample it was built on (six languages, plural correct) survived the population changing under it and then enforced the error.

Fixed by making the number derivable. _grammar.agreement(count) is a foundation returning the verb, pronouns and quantity for a subject of that size; the four sentences ask it and keep their own wording. A foundation rather than a helper on _anchor because the scorer and the presentation layer both need the answer, and presentation may not import scoring — an attempt to put it in _anchor was correctly refused by test_presentation_never_computes_a_score.

The architecture gate also caught the first draft returning a dict: a say["grade"] subscript reads, to the sweep hunting for score keys removed in ADR 001 stage 8, exactly like a report dictionary being indexed. A NamedTuple is unambiguous to that check and to a reader.

Closing test: test_every_grade_skin_prints_the_derived_unanchored_caveat in tests/test_unanchored_set_matches_corpus.py, which now derives its verb from the population instead of asserting the plural.

Mutation: the member broken is unanchored_sentence — the corpus note inside the score object, which no rendered skin produces and which the closing test reaches through _reference_block rather than by name. Hardcoding are there passes every skin assertion that names a renderer, because the note is data the scorer writes rather than a presentation the reader sees rendered.

D149 — Closed: pairing read filenames, and called 52 tested modules untested (High)

describe_tdd paired a production file when some test file's subject stem matched it: test_scoring.py covers scoring.py. That is one convention, stated as if it were the only one, and every repository that organises its suite by behaviour is reported as having untested production code.

Found by the tool doing it to itself. The 3.1.0 self-audit raised 58 unpaired-hotspot items — 58 of 146 production files. Measured against what the suite actually does:

Unpaired modules reported 58
Imported by at least one test file 52
Suite coverage 94.46%
Modules below 82% coverage 1

The module's own docstring had already named this failure, for the case of adding a language without teaching it that language's test naming: "reports every repository in it as having untested production code — a confident finding, wrong on every project, of exactly the kind this tool exists to prevent." The rule then did precisely that to the repository shipping it, because the warning was written about languages and the defect was about conventions.

Population. Every pairable language — PAIRABLE is eight suffix families — and every repository in any of them that does not name test files after modules. On this one, 52 of 58 findings were false. The Python ecosystem's ordinary pytest layout is behaviour-named, so the false-positive rate is not a property of this repository.

Fixed by widening the evidence, not the convention. A unit is paired when a test file names it or imports it. An import is a fact about the relationship; a filename is a guess about it, and the fact was already sitting in the test files unread. The scan reads Python, JS/TS, Java and Fortran import forms in the same pass that already counts test constructs, so it costs no additional file reads.

work-order items   60 -> 8
unpaired-hotspot   58 -> 6
paired files        7/146 -> 111/146
estimate, grade    4.1, C -> 4.1, C   (unchanged)

The first claim about this was wrong, and so was the correction.

The entry was originally deferred on the grounds that pairing feeds test_presence, which moves scanner_fingerprint and invalidates all 180 stored rows. That was asserted from how the change felt rather than checked, and it is false: _test_pairing is not in MEASUREMENT_PATH, and test_presence reads 5.0 either way — it asks whether tests exist at all, not what fraction is paired.

The correction then overreached in the other direction. It said the fingerprint was "byte-identical before and after the change (0f08242164497d73)". True of the pairing module in isolation. False of the commit, which also carried _metric_adapters.py and _selection.py, both in MEASUREMENT_PATH. Measured across the three commits of this slice:

eee8f4c  0f08242164497d73   before
08e4517  da4ac5e217816ff3   D149 landed, with the splits
116bc7f  c2b249230840d3cd   after the pillar work

So corpus reuse is invalidated by this slice and the next re-measure cannot reuse stored rows. The constants are not known to have moved — the splits are refactors and change no measurement — but that is a separate question from reuse, and the fingerprint is deliberately a byte digest so that nobody has to decide which refactors were safe.

Recorded rather than edited away because the mechanism is the entry's own subject: a measurement of one thing, stated about another. Found by a hostile audit of the commit that carried the correction.

Three bugs were found in the fix by its own falsifier before it shipped, each of which would have made the rule worse than the one it replaced: ./scoring.js resolved to js (extension read as a module segment, pairing every JS file against any test importing another); import y from './report' collected y, a JavaScript binding name, through the Python branch; and import static com.example.Helper.thing resolved to the member rather than the class. All three fail in the flattering direction — reporting less work than exists.

Six findings remain and they are honest: four tools/ scripts outside the coverage target, and _ranges_core and _html_report_sections, which tests reach only transitively.

Closing test: test_an_import_form_names_its_subject, test_a_file_extension_is_not_read_as_a_module_segment, test_a_javascript_binding_name_is_not_a_subject and test_a_module_imported_by_a_behaviour_named_test_is_paired in tests/test_pairing_reads_imports.py.

The falsifier's author wrote the fix again, recorded for the same reason as D145 — it is owed an adversarial read. It did catch three real bugs in that fix, which is the argument for writing it, not a substitute for review. Mutation: the member broken is test_a_module_no_test_names_or_imports_is_still_unpaired — the guard that the widened rule still finds real gaps. A fix that paired everything would satisfy every other test in the file and report a clean tree, which is the failure mode a widening invites and the one the reported instance cannot show. It sits outside the sample because every other case in that file asserts a pairing succeeds.

D150 — Closed: the terminal asked five of the seven setup questions (Medium)

docs/cli.md: an interactive first run on a TTY is "the same setup questions as chat/MCP". _first_run.maybe_prompt_first_run's own docstring: "One setup, not one per surface." It asked five of seven.

default_format is legitimately absent — ADR 011 §3 asks it on every invoke rather than once at setup. The other was economics, and nothing was asked in its place: a terminal user was never offered the economic scenario, never told it existed, and got reports without money in them having declined nothing.

Population. Every question setup_questions defines — the shared definition chat and MCP both render. Seven today.

How it survived. The prose claim was true of six of the seven, and a reader checking it would have found the questions they went looking for. The absent one is invisible from the terminal, because a question that is never asked leaves nothing on screen to notice. This is the shape D25 and D22 already recorded on this surface: the product had the capability and the door did not offer it.

Found and then not filed. It was reported in a wrap-up on 2026-09-09 as "CLI asking five of seven" and, when the D143 slice took the other three items in that list, left as a note. Nothing carried it. That is the corollary this register's own rule states — anything reported as still open gets filed in the same turn — and D151 is the entry about breaking it.

Fixed. The terminal asks the economics gate, and only the gate: the three labor rates stay the staged second ask economics_bounds_pending raises on the next call, the same shape the other surfaces use. Asking three rates of somebody who has just declined is the defect staging already fixed, and the terminal inherits the fix rather than a copy.

Closing test: test_the_terminal_asks_every_setup_question and test_the_economic_scenario_is_among_them in tests/test_terminal_asks_the_same_questions.py, which derive the population from setup_questions rather than listing it.

Mutation: the member broken is record_scan_history — removed from the terminal's answers while economics stays. It sits outside the sample because the reported instance was economics and the closing test names economics only in its second assertion; the sweep has to reach a question nobody has ever seen go missing, which is the whole reason it derives from setup_questions instead of listing names.

D151 — Closed: a release was cut while a known defect was unfiled (High)

The standing rule is "no release until the known-defect ledger is empty", and its corollary is that anything reported as still open gets filed in the same turn, because a ledger that gates a release has to be the whole ledger rather than a list plus what somebody remembers.

v3.1.0 was tagged at 17:13 on 2026-09-10. The caveat's grammar had been reported twice as a known wart — once in the 3.1.0 hand-off itself, in the same message that called the release clean. It was filed as D148 at 20:16, three hours after the tag.

So the register read zero open entries at release time, and the release was correct by every check that existed. The ledger was empty because the defect was never written down.

Population. Every release. Nothing in .github/workflows/release.yml read the register at all; test_written_record holds the disposition to describing the open headings, which keeps the register internally consistent and says nothing about whether a tag may be cut while entries are open. The rule was enforced by whoever was paying attention.

Fixed structurally, per the standing rule that an audit finding a class of bug ships the check that blocks the class. release.yml greps the register for open-entry headings and fails the build job if any exist, before anything is built or uploaded.

What the gate cannot do, and the first version of this paragraph got it wrong twice.

It does not "refuse the tag". Pushing a tag creates it; this workflow runs afterwards. What it refuses is the publish, which is the thing that matters, and it leaves behind a tag that has to be moved or deleted — exactly the mess D124 describes from the other direction.

It also ran only for a tag ref in its first cut, so workflow_dispatch — which this workflow's own header documents as the way to publish a chosen ref — walked straight past it. A gate on one of two publishing paths is not a gate. It is now unconditional, and test_no_publishing_trigger_skips_the_ledger_check parses the workflow rather than grepping it, because a condition is invisible to a substring search and a text search is what let the first version pass.

And it sees only filed entries. It cannot see a defect somebody declined to write down, which is exactly what happened here. That half is the corollary, and no check enforces "file what you know".

All three limits found by a hostile audit of the commit that shipped this entry, which had claimed the first of them as a capability.

Closing test: test_the_release_workflow_reads_the_register and test_the_gate_matches_the_heading_the_register_actually_uses in tests/test_release_needs_an_empty_ledger.py.

Mutation: the member broken is the gate's pattern, not its presence — changing the em dash in ### D[0-9]+ — Open to a hyphen. The step still runs, still greps, still reports "open register entries: 0", and passes every release while the register holds open entries. It sits outside the sample because the reported instance was an absent gate, and a test written from that instance would assert the step exists — which the mutated workflow satisfies.

D147's remainder, filed rather than left inside a Closed entry. That entry closed the half it named — load_config strips test_execution.requested from the repository tier, so a tree can no longer opt the host in. It did not close who chooses what runs.

expected_commands.test is deliberately not stripped, and that is correct: the key is documentation — "this is how you test me" — and require_test_command reads it, so removing it would fail a real gate on every repository that documents its command honestly. The comment above _HOST_AUTHORITY says exactly this, and ends: "the user tier is merged before this and is untouched … their answer is what opted_in_command reads."

opted_in_command did not exist. Three docstrings — in config.py and twice in _setup_persist.py — named it as the reader that made keeping the key safe, and nothing implemented it. run_test_suite read config["expected_commands"]["test"] from the merged config, where the repository's document lands by design.

So: a person consents to pytest -q, having been shown pytest -q. A repository documents expected_commands.test as something else. The host runs the repository's. The consent was real and attached to the wrong program — which is worse than an unclosed opt-in, because the audit trail says the person agreed.

Population. Every repository that documents a test command, on every surface, once any person has opted the suite in. test_execution.timeout_seconds rode the same path: unclamped, from the merged config, so a documented 2147483647 made this host wait sixty-eight years for a child it was told to run — D40's crafted-configuration family in a field that never got the bound its sibling analyzers.timeout_seconds has.

How it survived. The boundary was described accurately in three places and enforced in none. A reader checking the design found the right rule stated, and the citation named a function, and a function name in a docstring is indistinguishable from a function. This is the same shape as D23 and D25 in this register: the product had the capability described and the door did not implement it.

Two presentation claims went with it, both the same class of untrue sentence:

Closing test: test_user_opt_in_cannot_be_completed_by_repo_controlled_spawn_settings in tests/test_tree_cannot_opt_the_host_in.py, test_coverage_gap_notes_are_present_in_html_as_well_as_markdown in tests/test_html_section_parity_class.py, and test_chat_is_bounded_and_markdown_is_the_complete_report in tests/test_mcp_format_contract.py.

Mutation: the member broken is which tier the command is read from, not whether an opt-in is required — restoring config["expected_commands"]["test"] in run_test_suite while leaving suite_opted_in, the strip, and every opt-in assertion untouched. The suite still refuses to run unopted, the tree still cannot request a run, and the host still executes the tree's program. It sits outside D147's original sample because that sample was about the request, and a test written from it asserts nobody can opt in — which this mutation honours.

D153 — Closed: the config door enforced half of D34 (High)

authorize_config checked that a caller's config_path resolves inside the repository. That catches a config outside the tree. It cannot catch an inward link — root/link -> root/src — because that resolves to a path inside the repository and passes every containment check there is.

D34's rule is two halves for exactly this reason: resolve and bound, then walk the route as written, because an inward link shows only on the lexical path. repository_path does both, through _refuse_symlinked_route. This door did the first and not the second.

So two doors onto one rule disagreed on the same tree:

repository_path(root, "link", …) refused
authorize_config("link/…json", root) returned the target

Population. Every MCP audit_repository call that names a config_path. The audited tree supplies the directory the link lives in, so a repository could route this agent's configuration read through a link it planted and be believed — the class D34 exists to refuse.

How it survived. It was found here once already and closed elsewhere. The comment at the baseline seam in this same module records "an inward .maintainability -> src symlink … the D34 escape history refused and baseline did not", and baseline was fixed by switching to repository_path. The config door was never revisited, so the module carries a note describing this escape a few dozen lines below the place it was still live. The same shape as D82 leaving a refusal standing at the next door, which the existing comment in this function also records.

Found by the audit of 673e667, with the disagreement demonstrated against both doors rather than argued.

Closing test: test_a_config_reached_through_an_inward_symlink_is_refused in tests/test_mcp_server.py.

Mutation: the member broken is the lexical walk, not the containment check — deleting the _refuse_symlinked_route call while leaving the resolve-and-bound above it. Every out-of-tree config is still refused, the message is unchanged, and test_config_must_be_a_file_inside_the_repository passes. It sits outside that test's sample because the sample was an outward path, and containment is sufficient for outward paths — which is precisely why the inward case went unnoticed.

D154 — Closed: the delegate scanned a million lines, and the report never disclosed the child boundary (High)

Two findings on the secure-code-agent seam, both about scope nobody had stated.

The delegate was scanning other repositories' code. secure-code-agent.json excluded .venv, node_modules and tests/fixtures and nothing else this repository actually carries. MA's own config has excluded tools/validation/reports/ since those files existed — they are stored audit output about other repositories, 1,015,343 lines of it, lapack.json alone 574,130.

loc_scanned     957,219 -> 33,227
findings          4,929 -> 82

The inflated denominator was hiding, not adding. Five critical secrets findings sat inside 4,929 results over a million lines and the pillar read A-; over the real tree they are 5 of 82 and the gate fails. A denominator that large is absence-as-evidence wearing a score, and the security pillar MA renders was computed mostly over code nobody here wrote.

The five are false positives — gitleaks.curl-auth-user on curl -sS -u "$SONAR_TOKEN:" in quality-gates.yml, a GitHub Actions secret reference rather than a value; no hardcoded token exists in any commit. Left unsuppressed: suppressing a finding is a decision with its own evidence and does not belong in a scoping fix.

The report never disclosed the child boundary. product-intent.md has said since P1 was amended that this package "does not sandbox children", and lists "that third-party tools cannot use the network" among the things it does not promise. A reader holding a page headed "the analysis itself performs no network access" and a table naming eleven external tools that ran had nothing connecting the two. P8's gap rather than P1's: the promise was correctly scoped, and the run relying on that scoping never disclosed it.

The contract that found it demanded the other resolution — wrapping children in sandbox-exec/unshare/bwrap/firejail. That half is refused and the refusal is pinned: it contradicts a documented non-goal, and analyzers.acquire_tools is a shipped opt-in that needs the network, so a blanket deny would change which tools run and therefore what the evidence says.

Population. Every repository that runs both tools, for the scoping; every run contributing at least one external analyzer, for the disclosure.

One more found while fixing: test_docs_links globbed the tree and read secure-code-report.md — gitignored, 2.8 MB, whose regex patterns parse as markdown links. It passed in CI on a clean checkout and failed on any machine that had run the security gate, which is the noise that teaches a reader a red suite is a local artifact.

.secure-code/history.jsonl is now tracked for the reason .maintainability/history.jsonl is: a history that is not committed restarts from zero on every machine and every CI run, which makes the trend it exists to carry meaningless. Security is a reported pillar (ADR 007), so the delegate's series is part of this repository's record.

Closing test: test_the_report_discloses_that_children_are_not_isolated and test_the_disclosure_reaches_the_html_skin_too in tests/test_child_sandbox.py.

Mutation: the member broken is the exclusion of one generated directory, not the scanner's configuration as a whole — restoring tools/validation/reports/ to the delegate's scope while leaving every other exclusion in place. The scan still runs, still reports, still names its scanners, and the pillar silently returns to a grade computed over half a million lines of lapack. It sits outside the original sample because that sample was the child-boundary disclosure, and a test written from it never looks at what was scanned.

D155 — Closed: a delegated pillar's trend joined across the producer's own scoring change (High)

MA stores a delegated pillar's condition in its scan history like any other — pillars maps each pillar to its condition score. It did not store who produced it, at what version, and the comparability gate therefore could not see when the producer's scoring changed.

secure-code-agent changed its normalizer twice in one day — sqrt(LOC/1000) to straight density, then secrets exempted back — without touching the pillar document's schema. Same shape, same fields, same schema_version, a different condition for the same repository. MA would have drawn one continuous line through that.

That is this module's opening defect arriving through a tool we delegate to rather than one we run. _scan_history's own docstring: "a wrong snapshot is obviously a snapshot, while a wrong trend looks like knowledge." Every existing comparability field guards against an instrument we control. A delegated pillar is an instrument we do not, and it was the one thing nothing watched.

Population. Every repository whose history spans a secure-code-agent upgrade, for as long as the delegated pillar has been recorded. Widening as the delegation model is used for more pillars.

Found by the producer telling us. The sibling session working on secure-code-agent reported the normalizer change while confirming the schema was unchanged — which was the reassuring half. Nothing in either tool would have raised it: the document validates, MA accepts it, and the number simply moves. Had we relied on schema stability as the comparability signal, the series would have spliced silently and correctly by every check that existed.

delegated_producers is now a comparability field, carrying security:secure-code-agent 0.9.0 per delegated pillar. A producer change or a version change breaks the series and names itself. A document with no version still contributes its producer, because "same tool, version unknown" and "a different tool" are different facts and only the second should break a series alone.

A second defect surfaced while fixing it. _SEQUENCE_FIELDS carried the comment "derived from the dataclass rather than listed by hand so a new one is handled the day it is added" — and was a hand-written list of five names. The sentence was aspirational and cost exactly what it promised to prevent: the new field was missed, and a stored record stopped comparing equal to a freshly built one. It is now actually derived, and the round-trip guard that caught this is why the cost was one test run rather than a corrupted history.

The version was the first key, and it over-breaks. A docs-only or adapter-only release of secure-code-agent opens a new series having changed no scoring. The producer raised that itself rather than letting it surface later as a bug report.

The first answer here was to accept it and document the noise: the two errors are not symmetric, and over-breaking is the safe direction. That answer was wrong for a reason this repository already knows. Its own file-size limit was widened from 250 to 600 the same week, because 161 warnings nobody could act on is "a signal that fires constantly teaches people to ignore it." A trend that resets on a docs release is that signal, and shipping it with a note explaining the noise is documenting a defect rather than closing it.

So the contract gained the narrower field instead, agreed across the two tools before either released. scoring_model is the producer's own identifier for "a repository's condition could differ here for reasons that are not the repository": the normalizer, slope, weight tables, letter bands, rank discount, the scanner floor, or the built-in rule profile. Not docs, adapters, CLI flags, output formats, performance, or a parser fix that changes no findings.

A new rule bumps it, and that line is the producer's correction to a carve-out proposed from this side. A repository containing yaml.unsafe_load scores lower the day that rule ships without changing at all — adding findings is rescoring, because the score is a function of the finding set, and a reader takes "we can see more now" for "your code got worse".

MA reads schema v1 and v2, keying on scoring_model where the document declares one and the producer version where it does not. That is what removes the deadlock: either tool can release first, and there is no window in which documents are refused. Accepting v2 is not guessing past a schema — its shape was specified jointly and the producer asserts the key-set difference from v1 is exactly that field.

A v1 document is never collapsed onto one model id. The producer reserves model 1 and never emits it, because the releases before the field existed do not share a scoring model — its corroboration merge, rank discount and normalizer each moved the numbers independently. Falling back to the version fragments those correctly.

A third option — MA special-casing which producer versions changed scoring, from a list the producer maintains — was refused. It puts knowledge of another tool's internals in this repository, where it would go stale silently and where nothing could check it.

Closing test: test_a_delegated_producer_change_breaks_the_series in tests/test_child_sandbox.py.

Mutation: the member broken is delegated_producers' membership in COMPARABILITY_FIELDS, not the field itself — the record still stores the producer and version, the report still names them, and every existing comparability assertion passes. Only the trend silently rejoins. It sits outside the original sample because that sample was the child-boundary disclosure, and no test of a report's contents looks at how two reports are joined.

D156 — Closed: **/dir/ excluded nothing, at any depth (High)

**/__pycache__/ is the gitignore spelling for "this directory wherever it appears". In is_excluded it matched nothing — not at depth, not at the root, not anywhere.

Three branches and it fell through all of them. The directory branch compares against the literal prefix, so a path has to begin **/. fnmatch would need the path to end in a slash, which no file path does. The containment branch — the one that makes a bare dir/ work at any depth — is skipped for patterns holding glob characters, and ** is two of them.

So the two spellings of one intent differed by everything: dir/ excluded at every depth, **/dir/ excluded nothing. A user writing the form gitignore taught them got a rule that reads as intent, never errors, and silently scans exactly what it was written to exclude.

Population. Every repository whose config uses a **/ directory pattern, on every surface, for the life of the matcher. Silent in all of them — the excluded files enter the scanned population, inflate the denominator, and can move a score without a single message saying so. **/*.min.js and other file patterns were unaffected, which is worse rather than better: a config mixing the two forms had half its rules working.

Found by the producer we delegate to. The secure-code-agent session hit the identical defect in its own matcher — .pyc files scanned as source for that tool's entire life — and reported the class rather than only its instance, asking whether MA's config used the spelling. MA's own config does not. MA's matcher had the same bug, which is the part no instance report would have found, and it is why the question was worth asking across the boundary.

An inert exclude is the hardest kind of defect to notice: it has no symptom of its own. Nothing errors, nothing is reported, and the only evidence is findings in files the operator believed were excluded — which reads as the audit being noisy rather than the config being dead.

Fixed by honouring the prefix rather than by special-casing it: a leading **/ is stripped before matching, so **/dir/ reduces to the bare form that already worked at any depth.

And **/dir/ was one spelling of a wider defect. The first fix closed the reported instance and left the class: any directory pattern holding a glob matched nothing. *.egg-info/ was dead in this repository's own config while src/maintainability_agent.egg-info/ sat in the tree being scanned. Same three branches, same reason — the literal branch cannot see a *, fnmatch on the whole path wants a trailing slash no path has, and the containment branch is skipped because the pattern holds glob characters.

A trailing-slash pattern is now globbed against each directory component and each rooted prefix of them, so build/ still catches a/b/build/x.o and *.egg-info/ catches its directory at any depth. The final path component is excluded, because the trailing slash says directory and *.egg-info/ must not match a file named notes.egg-info.

Found by turning the producer's own lesson on this repository. They reported closing a liveness test that iterated only the directory half of their patterns — "half a structural rule, which is the kind that reads as covered". Sweeping MA's 35 configured patterns the same way found the dead one. That sweep is now a test, asserted over every pattern rather than a sample, and it is non-vacuous: run against the previous matcher it names *.egg-info/ and nothing else.

Only the directory form is rewritten. An earlier attempt stripped the prefix from every pattern and anchored **/generated/*.py to the repository root, breaking a case that had always worked — a file pattern already matches through fnmatch, where * crosses separators, so it needed no help and was harmed by it. test_exclude_patterns_use_glob_and_normalized_separators caught that inside a minute, which is the argument for a suite that covers the behaviour a fix is adjacent to and not only the behaviour it targets.

Two further tests ship with the fix and are deliberately not cited below, because both pass at the base: one bounds the fix against swallowing a similar name, the other pins the file-pattern regression the first attempt caused. Bounds and regression guards defend a change's blast radius rather than the defect, which the falsifier standard is right to treat as a different thing.

Closing test: test_both_spellings_of_a_directory_exclude_agree_at_every_depth in tests/test_operator_named_reads.py.

Mutation: the member broken is depth, not the prefix — handling **/dir/ only where it matches the path's own first segment. The bare spelling still works, the new pattern now matches something, and a test written from one example at one depth passes. It sits outside a single-example sample because the original bug was uniform: every depth failed, so any one case would have caught it, and only a sweep over depths catches a fix that is itself partial.

D157 — Closed: the same false claim, fixed in one place and left in the other (Medium)

_finish_result's docstring said chat and markdown are "on the wire the two are the same text" while the line beneath it already passed complete=(format == "markdown"). That was corrected in D152.

The MCP tool docstring went on saying it — mcp_server.py, inside audit_repository_tool: "chat or markdown (the same Markdown on the wire)". That is the docstring a host renders for the person choosing a format. The internal one was fixed; the exposed one was not.

An audit reported the claim, it was fixed in one location, and the next round found it still standing. A finding survived a full cycle because the fix closed the instance it was reported at. Third time in one day: D147 closed on a function that did not exist, D156 fixed one spelling of a dead exclude and left the class, and this.

Population. Every MCP host. The tool docstring is the contract a person reads when asked which presentation they want, so a reader was told the two file formats differ only in destination — when chat is deliberately trimmed to stay inside a host's payload cap and markdown is the whole report. Someone choosing chat to read inline and expecting completeness got a bounded document with nothing saying so.

The behaviour was right throughout. Both defects were the sentence.

Swept, not spot-fixed. A test now asserts no module in the package makes the claim, over src/**/*.py rather than over the two files that were wrong, plus a second asserting the exposed tool docstring names both skins. Fixing a location at a time is exactly what let this survive a round, and a guard written the same way would have too.

The sweep's first version flagged its own correction — _mcp_audit now says the two are not one text — so the pattern excludes the negation. Verified against both original wordings and both corrections.

Closing test: test_no_surface_claims_chat_and_markdown_are_one_text and test_the_tool_docstring_hosts_read_names_both_skins in tests/test_mcp_format_contract.py.

Mutation: the member broken is the exposed docstring, not the internal one — restoring "the same Markdown on the wire" to audit_repository_tool while leaving _finish_result's correction in place. Every D152 assertion passes, the behaviour is untouched, and the sentence a host shows its user is false again. It sits outside D152's sample because that sample was the function whose code contradicted its own docstring, and nothing there looks at the transport layer above it.

D158 — Closed: a stored field named rubric_version held the tool version (Medium)

The scan record's comparability field was called rubric_version and held the package version. A rubric version would move when the rubric moved; this moved on every release, including patch releases that changed no scoring at all — and it accounts for 38 of this repository's 49 series breaks.

Filed retroactively, and that is the second defect here. The displayed half was found and fixed on 2026-09-10 in 51e2060, shipped in 3.2.0: a field-to-prose map so a break reads "the tool version changed" instead of naming the field. It was never written down. The standing rule is that anything reportable as still open gets filed in the same turn, and D151 exists because a release was cut over a defect nobody had recorded. This is that failure in its quieter form — not a blocked release, an incomplete ledger, and the ledger is what the release gate reads.

It surfaced while drafting the roadmap's "claims without warrant" section, as specimen 4: the name that lies. Writing it up required citing where it was closed, and there was nowhere to cite.

The fix left in 3.2.0 was half of one. Correcting the prose made the report honest and left the record lying: anyone opening .maintainability/history.jsonl read "rubric_version": "3.4.0". A stored line is read by more than this tool's renderer, and the comparability key is computed from the field rather than from its display name.

Renamed to tool_version in history schema 5. Lines written under schemas 1 through 4 carry the old key and are mapped on read, because a stored history is the one artefact here that cannot be regenerated — a rename that dropped it would have cost more than the wrong name did. All 317 committed records load, and a history spanning the rename stays one series: the value never changed, only what it is called, so a break there would have been an instrument change that never happened.

Population. Every stored scan record, since the field existed. The report's prose has been correct since 3.2.0; the record has not.

Closing test: test_a_line_written_before_the_rename_still_loads and test_the_two_spellings_land_in_one_series in tests/test_scan_history.py.

Mutation: the member broken is the read-side mapping, not the rename — dropping the rubric_version to tool_version translation while keeping the new field. Every new line still writes and reads correctly, the whole suite passes on a fresh history, and 317 committed records silently lose their tool version and collapse into one comparability bucket. It sits outside a rename's obvious sample because that sample is "does the new name work", and the answer is yes.

D159 — Closed: the bounded work order spent its whole budget on one class (High)

The prompt is the product, and it was showing a stranger the same item twelve times.

prompt_items takes the top twelve eligible findings. It did not group, so a class with many members takes every slot. Measured on a real tree with the shipped example configuration:

work-order items 125
risk-pattern 100
duplicate-block 24
oversized-file 1
paste slots given to risk-pattern 12 of 12

Six of the twelve named the same file, with identical titles. The other two classes got nothing.

The prompt opens "Work in this order. The first items are the highest value for the least change" and then spent its entire budget on one rule while omitting two classes outright — so the sentence the product leads with was not true of the list beneath it. The item already carried class_count: 100 and class_delta: +0.20; the tool knew they were one class and printed them one per line anyway.

Population. Every repository with more than twelve members in any one finding class, on every surface, since the paste existed. The commonest shape there is: configured risk patterns and duplicate blocks both count per occurrence, so this is the ordinary case rather than the edge.

Found by running the README's own quickstart as a stranger would, against a real tree rather than a fixture. A value audit named the surrounding problem — "the first screen cannot be seven questions and a B" — and the first screen turned out to be worse than the framing: not ceremony, one item repeated.

One row per class now, keeping the existing order so Severe still leads and the economic ordering behind it still holds. The row states its reach — "First of 68 across 10 files" — because it keeps its own title, and a title naming one file would otherwise read as one finding.

A second defect was introduced fixing the first, and a guard caught it. prompt_targets derives from prompt_items on purpose, so recurrence scores exactly what was advised. Collapsing the rows collapsed the targets: 68 findings advised, one fingerprint recorded, and a later run would have scored 67 of them as never asked about. test_prompt_targets_records_both_overloads failed immediately.

The two are now one decision with two views — prompt_advised expands the classes prompt_items names, rather than selecting again beside it, because two derivations of "what did we ask for" drift and the one that drifts is the one nobody reads.

Closing test: test_the_paste_is_kinds_of_work_not_repeated_rows and test_what_the_prompt_advised_covers_the_whole_class in tests/test_finding_identity.py.

Mutation: the member broken is the expansion, not the grouping — keeping one row per class and pointing prompt_targets back at prompt_items. The paste still reads correctly, every display assertion passes, and the durable loop silently forgets all but one member of each advised class. It sits outside the display sample because that sample asks what the prompt shows, and this is about what it remembers.

D160 — Closed: the chat door opened with a metric table, not the work order (High)

The primary surface put the product on line 51 and the score on line 8.

docs/product-intent.md is not ambiguous about which is which: "The scanner and the score exist to aim the remediation prompt. If the prompt were removed, the rest would be a worse version of tools that already ship." And: "Step 3 is the product." The bounded view — the MCP chat format, which the skill calls the surface most users drive this tool through — built ## Summary into the shared prelude, so every chat reply opened with four metadata lines and a fourteen-row metric table before anything said there was work to do.

Measured on a tree with three fat modules, rendered as chat:

before after
first line of ## Work Order 29 8
first pasteable prompt 51 36
lines before the letter grade 8 60

A second half, found while measuring the first: a run with no work order rendered no work-order section at all. This repository is that run — 511 files, 74 function warnings, and an empty backlog. Its chat view is 73 lines across six headings and never once mentions a work order. A stranger auditing a clean tree could not distinguish nothing met the bands from this tool does not produce work orders, and on a first run the second reading is the available one. The section now renders either way and says which case it is.

Hard gates still lead, because a failed gate changes which item you start with. The summary, the capped-grade reasons and the score tables follow the work order as the evidence that aimed it — the same sections, in the order a door needs rather than the order a document needs. The complete report file is unchanged: it is read top to bottom and correctly opens with what it is.

Population. Every chat and MCP chat reply this tool has produced. The CLI renders complete=True and is unaffected.

Found by a value audit, item 4 of nine: the default skin should lead with work orders, not the grade, with the copy-paste items above the letter. The audit's framing was a first-screen problem; the empty-state half was not in the audit and turned up in the rendering.

The paragraph rule arrived in the same turn and is bound here too: a prompt handed to an agent is a paragraph or two, not a whole program. Measured rather than assumed: one item's paste is 710 characters, and the whole remediation prompt is 96 lines on a 171-item backlog because prompt_items caps it at twelve. Nothing enforced that cap — removing it would have left every test green while handing an agent the entire backlog — so it is now a guard.

Closing test: test_the_chat_view_shows_the_work_order_before_the_grade, test_a_pasteable_prompt_is_near_the_top_of_the_chat_view and test_a_run_with_no_work_order_says_so in tests/test_chat_leads_with_the_work_order.py. The two size guards in the same file declare themselves as covering existing behaviour.

Mutation: the member broken is the empty-state branch, not the order — restoring work_order_markdown's bare result so a clean run renders nothing where the section belongs. Both ordering assertions still pass, because a tree with findings never takes that branch; only the repository this tool audits most often goes quiet again.

D161 — Closed: --prompt-output - wrote a file named - (High)

The product is a prompt you pipe into an agent, and the pipe silently produced nothing.

- is the POSIX spelling for stdout. Every rendered output went straight to Path(name), so - was a filename. A reader running

maintainability-agent --config c.json --prompt-output - | claude

got an empty pipe, a report on stdout they did not ask for, and their prompt on disk under a name their shell fights them over. Nothing errored. Nothing said so.

The value audit named this command shape in item 3 — install, example config, --format markdown --prompt-output -, done — as the five-minute path a stranger should get. The flag it recommends did not work.

Population. Every rendered output, since each existed: --output, --prompt-output, --comment-output, --agent-instructions-output, --attestation-output, --hostile-prompt-output, --sarif-output.

- now writes to stdout on all seven, through one _emit seam so a future output cannot be added with the old behaviour by accident.

A second defect the obvious fix introduces. With no --output, the report prints to stdout. Sending the prompt there as well hands a reader a prompt with a full report stapled to its front — worse than the file, because it looks like it worked. A secondary output claiming stdout now suppresses the implicit report; an explicit --output - still prints it.

Two outputs refuse - by name and with a reason, rather than accepting it and doing something else: --write-baseline is read back by a later run, and --html-output is opened rather than piped. That refusal is the same defect stated as a rule — an output that takes - and does not stream is the failure.

Found by running the demo tree's own documented command while building it for the audit's item 5, not by reading the flag table.

Closing test: test_a_rendered_output_reaches_stdout_and_writes_no_file, test_the_report_does_not_interleave_with_the_piped_artifact and test_the_outputs_that_cannot_stream_refuse_by_name in tests/test_stdout_outputs.py.

Mutation: the member broken is the suppression, not the stream — restoring the implicit print(rendered) beside a piped artifact. Every output still reaches stdout and every stdout assertion about the artifact passes; only the interleaving test fails, which is the half a reader actually pipes.

D162 — Closed: the advertised five-minute config does not open the languages the README claims (High)

The copy-paste the README rebuilt for a stranger hides 11 of 14 parsed languages.

maintainability-audit.example.json is what the README tells a stranger to curl and pass as --config. Its paths.include_extensions is a web subset: .py, JS/TS, .html, .css, .md. Default config opens every suffix in DECLARATION_SUFFIXES. The example is a replacement, not a merge.

Measured on 76cd40d against a one-function Go tree, following the README command:

example config defaults
files scanned 1 (README.md) 2
.go Source Not Read applyPricing at complexity 14
work order absent one quick-win

The report names the miss (| .go | Go | 1 |) and tells the reader to add the extension. The file they were told to download is what removed it. Same hole for .java, .rs, .rb, .php, .swift, .cs, C/C++, Fortran, COBOL.

Population. Every stranger who follows the README one-command on a repository whose production suffixes are not in that web list. MCP first-run still writes the full default; this is the CLI path 3.5.0 advertised as the first five minutes.

The list is no longer hand-written. It is the shipped default's own set, 48 suffixes, so a language added to DECLARATION_SUFFIXES reaches the advertised config in the same change rather than whenever somebody remembers this file. The test derives the expected population from production source, never from a list restated beside it.

Closing test: test_example_config_opens_the_source_derived_default_language_population in tests/test_example_config_opens_claimed_languages.py.

Mutation: a member the test does not name — a .rs or .java tree, not the Go fixture — must fail while include_extensions in the example omits it.

D163 — Closed: the advertised product file still leads with the grade, and issues rates the audit withheld (High)

--prompt-output is the file the README calls the work order. It is still a score sheet, and on the demo it contradicts the demo.

D160 reordered the MCP chat view. The command 3.5.0 tells a stranger to run is CLI --output report.md --prompt-output prompt.md. That prompt.md is render_ai_prompt: preamble, audit summary (estimate, verified grade, evidence), then work items. The copy-paste work order the demo pins (expected-prompt.md) is work_order_markdown, a different emitter, and is not what --prompt-output writes.

On examples/demo at the shipped floors: score.overall is None, evidence insufficient, calibration floors not met. The demo README: "only the rates are withheld." The prompt that command writes still says:

P7: a score is issued only where enough was examined. A corpus multiple is a score. The complete markdown report on the same run does not print those multiples; only the advertised product file does.

Population. Every --prompt-output / MCP remediation_prompt on a tree whose overall is withheld, and every stranger who opens prompt.md expecting the work order the README described.

Two changes, one per half. render_ai_prompt now emits the work order directly after the rules that bound it, and everything describing the run rather than the task follows — the D160 reordering, applied to the file the CLI path actually hands a stranger. And prompt_pressure_section prints nothing when view.is_scored is false: a corpus multiple is a rate against a calibrated median, so a run that withheld an overall on too little evidence cannot rank the same repository's dimensions against that corpus. The grade blockers still render, because those say why no number was issued.

The work order is untouched and still names every finding. A finding is an observation, not a rate, and withholding those would leave an undersized repository with nothing at all — which is the opposite of what this entry is about.

Closing test: test_unscored_prompt_has_no_dimension_multiple_or_typical_comparison and test_unscored_prompt_leads_with_work_order_before_summary_or_grade in tests/test_prompt_does_not_issue_rates_when_unscored.py.

Mutation: a member the test does not name — any tree below a population floor, not examples/demo — must fail if the prompt still prints a dimension multiple after overall is None.

D164 — Closed: a work item described a 2876-line class as a function, and sized a change the audit never measured (High)

Found running the top work item on a Java repository through the chat door. The item misstated what it was about.

On jhy/jsoup the work order's first row read HtmlParserTest … reduce below the configured limits (currently 2876 lines, complexity 25), band quick-win, Why: "a long, branching function is where defects concentrate … extracting one is bounded, local work". HtmlParserTest is a class. A class is graded on length alone against max_class_lines (300), and its complexity is the sum of its methods' branches, already counted against those methods — so the item gave a class a function's reading, against no limit at all, and then asserted the change was bounded.

P5 requires the prompt to name only what the audit produced. ADR 007 §3 makes effort "a judgment call to be stated in standard.md" — declared per finding class. "Extracting one is bounded, local work" is that class-level judgment, published beside effort 2. Printed on one item it became a measurement of that item, and nothing measured it: the same sentence sat on a 90-line function and on a class ten times its limit. A reader sizing work from the report — a person or an agent pricing it on the fly — was handed the class's estimate in place of the instance's fact. unpaired-hotspot carried the same per-item claim ("adding a characterization test is bounded, local work").

Population. Every oversized-declaration and unpaired-hotspot item, on every language whose scanner emits a class, in every skin that prints a target or a Why — including check_content, the mid-edit check, whose docstring promised the audit's wording and which kept its own copy of the target.

No effort is computed and the band is unchanged — it stays declared per class, and standard.md now says so in as many words. What changed is what an item states. The target names each figure against the limit its kind is graded on: currently 2876 lines against the 300-line class limit for a class; lines, complexity and cognitive cost each against their own limit for a function. The thresholds reach the builder from build_report, the one call site that holds them. The Why is kind-aware and describes why the finding matters, without the sizing clause. The class judgment stays in the standard, where ADR 007 put it.

Closing tests: test_every_declaration_item_names_the_limit_its_own_kind_is_graded_on, test_the_in_loop_check_words_a_breach_as_the_work_order_does, test_no_work_item_rationale_sizes_the_change_it_asks_for and test_an_unpaired_item_does_not_size_the_test_it_asks_for in tests/test_work_items_state_their_own_finding.py.

Amended 2026-09-13 (D171). "In every skin that prints a target" was false when this closed: the pre-commit hook, a third caller of the same builder, was never given the thresholds, and that was noticed during this fix and not filed. D171 closes it.

Mutation: the class limit read from max_function_lines instead of max_class_lines, which breaks the C#, Fortran and Java classes the grammar sweep scans and not the jsoup class that motivated this. It was first missed: the fixture set both budgets to 1, so either printed the same number. The budgets are now different values and the mutation fails the test. The author of the fix wrote the falsifier, which is exactly the blind spot a separate test author exists to prevent, and the missed mutation is that blind spot showing.

D165 — Closed: a risk-pattern item asked for a TODO to be deleted, and credited shipped defaults to the repository (High)

On the same run, the prompt's first item was configured risk pattern in HtmlTreeBuilder.java — remove the configured risk pattern, and the matched line was // todo - probably just create new parser objects to ensure all reset.

Read literally, the instruction is "delete the comment". That clears the finding and moves the estimate +0.10 for the class while the debt the marker records stays exactly where it was — the one remediation a hostile reader would point at. The Why said "a configured risk pattern is a rule this project chose to enforce on itself". jsoup has no configuration; debt-marker is one of DEFAULT_CONFIG's shipped rules. The pre-commit item said the same twice: "a pattern this repository's own configuration asked to be told about", "the repository declared this pattern as risky".

P8 requires every reported value to carry an attributable source; "this project chose" is a false one for every repository that did not replace risk_patterns, which is most of them.

Population. Every rule in DEFAULT_CONFIG["risk_patterns"], on both surfaces that turn a match into an instruction: the audit's work order and the pre-commit hook.

The target now names the rule and asks for what the match records: "act on what the debt-marker rule matched here, or say why it stays; deleting the matched text alone hides the finding without resolving it". The rationale and both pre-commit strings attribute the rule to "the active configuration", which is true whichever tier supplied it. Band, risk and effort are unchanged.

Closing tests: test_a_risk_item_names_its_rule_and_does_not_ask_for_the_match_deleted and test_no_risk_surface_attributes_a_rule_to_the_repository_choosing_it in tests/test_work_items_state_their_own_finding.py.

Mutation: the pre-commit item's target restored to "this repository's own configuration asked to be told about" — the hook surface, which the demo never showed — fails the attribution test.

D166 — Closed: the verify command in every work item addressed whatever python a shell found (High)

Every item ended Verify when done: python -m maintainability_audit --root . --format json. On the Mac the demo ran on, python does not exist; python3 resolves to a 0.9.1 user-site install.

So the check an agent is told to run after a fix either fails to start or re-audits with a release three majors older than the one that issued the item. That is D144's defect on a different command: the environment work order's install remedy "addressed whichever pip PATH found", and D144 bound it to sys.executable. The verification string was a literal in all eight class weights, the semantic item builder and the view's fallback.

Population. Every finding class in CLASS_RISK_EFFORT and every skin that prints a verify line — the remediation prompt's Verify with:, the work order's Verify with: and each copy-paste block's Verify when done:.

One constant, AUDIT_VERIFICATION, built the way D144's _pip_command is: shlex.quote(sys.executable) -m maintainability_audit --root . --format json. The demo's checked-in work order normalises that interpreter to python3 exactly as it already normalised the root to ., so the golden file is not machine-specific. Regenerating it surfaced a second fault: tools/regen_demo_prompt.py loaded the regenerating user's own configuration, so a personal economic context reordered the checked-in demo while test_demo_tree, isolated by conftest, rendered the shipped order. The tool now isolates the user tier the same way.

Closing test: test_every_verification_command_runs_the_interpreter_that_audited in tests/test_work_items_state_their_own_finding.py.

Mutation: only competing-libraries restored to the bare python literal — a class the demo tree never produces — fails the test through the declared-weights half of its population.

D167 — Closed: every grade skin said COBOL grades are provisional, on repositories with no COBOL (Medium)

The Java repository's chat view, prompt, Markdown and HTML reports each printed "COBOL is parsed but is not in the reference corpus, so a grade reported for code in it is provisional".

D143 and D148 made the anchor's gap reach every skin that prints a grade, and it does. But unanchored_caveat read only score.reference.unanchored_languages — a fact about the corpus — and never the run's own languages, so the sentence appeared on every report this tool produces. The disclosure's purpose, stated in _anchor, is that the limit sits "where the grade is read"; a Java repository's grade is not qualified by the corpus lacking COBOL, and telling its reader otherwise is a false statement about the number in front of them.

Population. Every unanchored language, on every grade skin: bounded and complete Markdown, HTML, and the remediation prompt.

The caveat is intersected with summary.languages, the census every report carries with or without the analyzer pool. A report without that census — one stored before it existed — cannot rule a language out and keeps the full disclosure. A tree that does contain an unanchored language still discloses it on every skin; that half is guarded so the narrowing cannot overreach into the D143 class.

Closing test: test_no_skin_discloses_an_unanchored_language_the_run_did_not_scan in tests/test_anchor_caveat_and_counts_follow_the_run.py.

Mutation: only the HTML executive strip ignores the census — the demo showed the chat prompt, not HTML — and the test fails on the html skin.

D168 — Closed: the prompt and the corpus note on one report gave the corpus two different language counts (Medium)

The prompt said the reference medians came from "eight of the ten parsed languages"; score.reference.corpus_note on the same report said thirteen of fourteen.

Both were typed as prose. The note's count happened to be current; the prompt's had been stale since the corpus grew past ten languages, and nothing failed when it went stale. P6 requires every quoted number to be re-derivable; two contradictory counts cannot both be.

Population. Every rendered surface that states a count of the corpus's languages — the four grade skins and the corpus note.

Both sentences now count corpus_languages and unanchored_languages, the two lists the reference block already publishes. The prompt reads them off score.reference so a stored report renders its own run's counts.

Closing test: test_every_stated_count_of_corpus_languages_matches_the_corpus_lists in tests/test_anchor_caveat_and_counts_follow_the_run.py.

Mutation: only the corpus note miscounted by one — the demo showed the prompt's count — fails the test on corpus_note.

D169 — Closed: the work order's heading claimed the band order over a list the exposure sort had replaced (Medium)

The same run's table led with HtmlParserTest and the prompt with the risk-pattern class, while the table's heading read "Ordered by what it costs to leave against what it costs to fix" and the prompt's "The first items are the highest value for the least change".

Both lists come from report["work_order"]. With an economic context configured, reorder_by_exposure (ADR 004) re-sorts it by recurrence and churn and ignores band; the prompt then promotes risk-5 classes and keeps one item per class. Neither heading described the order it sat over, so the reader saw two different first items and two claims that each was the priority. standard.md said class_delta "is what the ordering uses", which is false whenever the exposure sort ran.

Population. Both work-order skins — bounded and complete Markdown — and the remediation prompt's order sentence.

No ordering changed. The heading states the sort that produced the list, read from the same condition build_report applies it under (economic_impact is attached exactly when the exposure sort runs). The prompt's sentence describes its own rule rather than a value claim. standard.md states both orders.

It surfaced a question for decision rather than for this fix: ADR 007 §3 rule 5 says Fill-Ins never appear above Quick Wins, and the exposure sort could place them there. Decided and closed as D170.

Closing test: test_the_work_order_heading_states_the_order_the_list_is_in in tests/test_work_items_state_their_own_finding.py.

Mutation: only the complete Markdown report ignores the exposure sort — the demo showed the bounded chat view — and the test fails on its heading.

D170 — Closed: with an economic context configured, the work order could place a Fill-In above a Quick Win (Medium)

ADR 007 §3 rule 5: "Remediation output is ordered by the Risk × Effort matrix, and Fill-Ins never appear above Quick Wins." ADR 004: "The work order reorders by exposure."

reorder_by_exposure implements ADR 004 by re-sorting the whole work order on (-exposure, title), where exposure is 10 × recurrence + churn on the item's path. Band is not in the key. A Fill-In on a file that changes often therefore sorts above a Quick Win on a stable one, which is the nit-loop ADR 007 §3 exists to prevent: low-risk work in the position reserved for the work that matters. D169 made the heading say which sort was applied; it did not make the sort obey the rule.

The two ADRs are compatible as written. ADR 004 requires that exposure reorder the work order; it does not require that exposure outrank band. Decided 2026-09-13: exposure orders items within a band, and band order stays primary.

Population. Every work order built under an economic context, across every band pair ADR 007 §3 ranks.

Filed Open first, from the conflict D169 surfaced, and closed in the same change: the register refuses to carry an Open entry onto main, so the filing is recorded here rather than as a commit of its own.

reorder_by_exposure now sorts on (band, -exposure, title), reading BAND_ORDER — taken from Band's declaration order and shared with the band sort itself, so the two cannot disagree about the matrix. Exposure is untouched within a band. ADR 004 is amended to state "within each band", standard.md says the same, and the heading D169 introduced now reads "ordered by band, then by exposure within each band".

Closing test: test_no_exposure_lifts_a_lower_band_above_a_higher_one in tests/test_exposure_never_outranks_band.py.

Mutation: the sort keeps only Quick Wins above everything else — so the Fill-In-under-Quick-Win pair ADR 007 names still holds — and the test fails on major-project against fill-in and reconsider, pairs the rule does not name.

D171 — Closed: the pre-commit hook worded a declaration unlike the audit it mirrors (High)

From the hostile audit of 77de21c. D164 gave _items_from_hotspots the thresholds so an item names the limit its kind is graded on, and passed them from build_report and check_content. staged_findings — the third caller — was never given them.

A staged 2876-line class blocked the commit with currently 2876 lines; a class is graded on length alone, while the audit of the same class said currently 2876 lines against the 300-line class limit; a function lost its limits and cognitive cost the same way. The hook's own contract is that it writes no remedy text and feeds the audit's builders so a blocked commit reads the report's words. P8 requires a reported value to carry its source; two wordings of one finding from one builder give the reader two.

This was known when D164 closed and was not filed. The D164 session noted that the pre-commit path held no thresholds and would state figures bare, and D164's entry still claimed the wording held on every surface. That is D151's shape — a known gap carried unfiled through a release — and the entry above is amended to say so.

Population. Every declaration the scanners emit, staged through the hook and audited through build_report, compared at the same location.

staged_report now carries the thresholds it graded with, and staged_findings passes them to the builder.

Closing test: test_the_hook_words_every_declaration_as_the_audit_does in tests/test_every_surface_says_what_the_run_found.py.

Mutation: the hook's thresholds stripped of the cognitive limit — which changes function targets, not the class the audit reported — fails the test.

D172 — Closed: the prompt named a source for a maintainability estimate that was not issued (Medium)

The audit's seed run, changed-only over two commits, printed Maintainability estimate: Not scored and, a few lines below, "The maintainability estimate above uses the analyzer readings for declarations".

D163 stopped the prompt issuing rates when the overall is withheld. prompt_analyzer_caveat never consulted whether it was: with analyzer output present it attributed the estimate to the analyzers, or — the branch the audit did not name — said it "comes from the built-in detectors". Both describe the provenance of a number the run declined to produce, which is P8 facing the other way on the artifact D163 was about.

Population. Every state of analyzer_scored_dimensions — none, and each dimension the score document names — on a report whose estimate is withheld.

When is_scored is false, the caveat says no estimate was issued and that analyzer output is evidence about the code, never a change to a score.

Closing test: test_no_prompt_names_a_source_for_an_estimate_that_was_not_issued in tests/test_every_surface_says_what_the_run_found.py.

Mutation: only the analyzer branch gated on is_scored, leaving the built-in-detectors branch — which the audit did not report — fails the test on the no-dimensions state.

D173 — Closed: a run whose report said "Nothing to do" handed the agent a patch to make (High)

A warn-only tree produced no work order. The chat report said "Nothing to do. … there is no prompt to paste". The remediation prompt from the same run opened with "Your task is to fix the highest-value maintainability issues", listed Function hotspots to inspect first, and closed "Make the smallest coherent patch".

The skill tells a chat host to treat remediation_prompt as the bounded work order, so the host patches the warn-band functions the work order refused. On the audit's seed run the evidence note said "must not widen the work order" and the next section listed those functions. P5 requires the prompt to name only work the audit produced; D163's ordering test only runs when there is work, so the empty case was never examined. The same split held for a second case the audit did not name: a work order whose every item is withheld from the prompt — a Major Project, or a finding escalated for design review — still produced the task, the hotspot list and the deliverable.

Population. Both runs with nothing for an agent: an empty work order, and a work order withheld whole.

The "Nothing to do" sentence is one constant, NOTHING_TO_DO, printed by the chat report and the prompt. With nothing in scope, the prompt carries that sentence — or "Nothing in scope for this prompt" and what was withheld — and no task line, hotspot focus or deliverable. The rules stay: they bound what an agent may do whether or not there is work. A run with work is unchanged, and guarded as such. A report with no work_order key at all — stored before the field existed, or assembled by hand — cannot say there is nothing to do and keeps the prompt it always had; the first version of this fix read the missing key as an empty work order, and test_renderers_cover_findings_and_instruction_notes caught it printing "Nothing to do" over failing hotspots. test_class_label_reaches_the_prompt_the_pr_comment_and_sarif asserted a warn-band class label in a prompt with no work; it now gives that prompt one work item, because a warn-only prompt listing hotspots is the defect.

Closing tests: test_a_run_with_no_work_order_hands_the_prompt_no_task and test_a_work_order_withheld_whole_hands_the_prompt_no_task in tests/test_every_surface_says_what_the_run_found.py.

Mutation: "has work" read off the raw work order rather than the items the prompt keeps — right for the empty case the audit reported, wrong for the fully withheld one — fails the second test.

D174 — Closed: a changed-only run handed the prompt a semantic finding from a file the change never touched (Medium)

Changed-only over HEAD~2...HEAD scanned 33 changed paths and nominated tests/fixtures/semantic_ts/src/operations.ts — in none of them — as a semantic candidate in the prompt.

_attach_semantics ran the semantic walk over the whole tree and ignored only_paths. A changed-only report states its scope as the change; a finding from outside it is work the change did not create, handed to an agent told to stay inside it (P5). This predates 3.6.0: the audit's seed run is what made it visible.

Population. Every located semantic finding in a tree that holds the semantic fixture at more than one place, under a change that touches one of them. This repository holds a single located finding — the reported instance — so a population drawn from it would have been the sample.

A changed-only run keeps a semantic finding exactly when its source_evidence.path is in the change; one with no located path could not be placed in or out of it and is not actionable work in any case. A full run is unchanged. The audit also suggested skipping tests/fixtures in the semantic walk; that would change what a full audit examines, which is product intent, and is not done here.

Closing test: test_a_changed_only_run_hands_over_no_semantic_finding_outside_its_paths in tests/test_every_surface_says_what_the_run_found.py.

Mutation: the filter drops only paths under tests/, where the reported fixture lives, and the test fails on the copies under lib/ and services/.

D175 — Closed: a build changed what the audit scored, because its output was read as the repository's code (High)

Found verifying the demo loop on jhy/jsoup. After mvn test put jsoup's test resources in target/test-classes, the re-audit scanned 261 files instead of 232, gained a failing file (target/test-classes/htmltests/large.html), and doubled the risk class's worth from +0.10 to +0.20 — with no source change behind any of it.

P1 holds the audit deterministic over the same tree. It was deterministic over the working tree and not over the code: one commit scored one way before a build and another after it, and a CI checkout scored differently from the laptop that ran mvn test. ADR 010 exists so that code the team did not write leaves the scored population. The defaults excluded build/, dist/ and node_modules/ by name and not target/, and ADR 010 §1 forbids closing that by adding the name: it is the ggml/ mistake with one more entry.

Two gaps, found in order. The first fix recorded target as generated from the repository's ignore rules and changed nothing measured: discovery classified only files with a source-code suffix, so a generated tree holding HTML, XML and JSON contributed nothing to not_ours and every one of those files stayed scored. That second gap predates D175 — a package.json-rebuilt directory's HTML leaked the same way.

Population. Every suffix the shipped include_extensions reads, placed in an untracked path the repository ignores; and the three things that must stay scored — a tracked file under an ignore rule, an untracked file no rule matches, and a path only a machine's global ignore file excludes.

ignored_untracked_paths asks git, through the hardened run_git, for untracked paths the repository's per-directory .gitignore files exclude — never --exclude-standard, which would read .git/info/exclude and core.excludesFile. Discovery records them as generated with that evidence, and now classifies every file the scanner reads under a generated or vendored tree, not only source. The same built jsoup copy audits at 232 files, 16 file failures and 112 function failures, identical to the unbuilt tree, with the 28 build files counted in summary.generated_files. ADR 010's evidence table is amended.

Closing test: test_ignored_untracked_build_output_leaves_the_score_and_is_counted in tests/test_build_output_is_not_the_repositorys_code.py.

Mutation: only .html classified under a not-ours tree — the suffix of the file the demo exposed — fails the test on the JSON, XML, Markdown and source suffixes the sweep also places there. A second mutation, reading --exclude-standard, fails the global-ignore guard.

D176 — Closed: the chat view and the remediation prompt said nothing about the security pillar, measured or not (High)

Reported from an audit run in the ChatGPT desktop app: MA ran, and the security pillar was not in what came back.

ADR 007 declares the security pillar delegated and reported "so that silence is never read as safety". The JSON report, the complete Markdown and the HTML all carried it. The bounded chat view — the persisted default through the chat door, and so what almost every reader sees — never called pillars_markdown at all, and the remediation prompt had no pillar section. A pillar that was never measured and one that was measured clean rendered identically: as nothing. That is this register's opening theme, capability wired to the TTY, viewer shipped to the primary surface, on the pillar block.

Fixing the display exposed a second fault in it. Every skin explained unverified as "clean scan, but nothing prevents tomorrow's regression" — true for §2's high-condition, low-practice cell, and false for a pillar with no condition at all. secure-code-agent's partial-coverage pillar, practice level 5, rendered as a clean scan. At the base the same misreading applied to every posture carried with no condition.

Population. Every pillar in PILLARS, through the chat view, in each state the security pillar can be in — unmeasured with a reason, measured, and ungraded; the prompt in the same three; and every posture in POSTURE_NOTE with no condition.

The chat view renders the pillar block through the same pillars_markdown the complete report uses, so the two skins cannot disagree about a pillar (ADR 011). The prompt states the security pillar as run evidence — who measured it, posture, practice and condition, or why it was not measured — and names its findings as that tool's work order, not this one's. pillar_cells, shared by Markdown and HTML, says "not graded" and the producer's own reason when no condition exists.

Closing tests: test_every_pillar_reaches_the_chat_view_and_the_prompt and test_an_ungraded_pillar_is_never_described_as_a_clean_scan in tests/test_security_pillar_runs_and_shows.py.

Mutation: the chat view rendering pillars only while security is unmeasured — the reported state — fails on the measured and ungraded states; the prompt stating security only when measured fails on the unmeasured one.

D177 — Closed: the audit did not run the security pillar it declares complete (High)

Amended 2026-09-13 (3.7.2). "secure-code-agent becomes this package's one runtime dependency" is reversed. The range was copied from this project's CI pin (>=0.10,<0.11) while the published tool was at 0.12.1, so installing 3.7.0 downgraded a current machine; and the dependency put secure-code-agent's mcp<2 pin beside this package's mcp>=2 (D178). secure-code-agent's D3 and ADR 008 both hold the tools independently releasable. The audit still runs it on every audit; it now runs whichever release is installed within >=0.12.1,<1, and a missing or unsupported release is the pillar's stated reason with an install command (test_an_unsupported_release_is_stated_not_run).

The same report. Investigating D176 showed the display was only half of it: on the Mac the audit ran on, no measurement existed to display.

The integration built in #208, #213, #214 and #216 was a handoff. secure-code-agent wrote security-pillar.json; this tool read it if it was there. CI runs secure-code-agent before this tool and passes the document, so every pull request carried a measured pillar. Nothing else ran it: not the CLI unless an operator ran the other tool first, and never the chat door, which has no way to pass a document. #214 recorded the same shape — "wired everywhere except where it runs" — and fixed it for CI only. Decided 2026-09-13: the pillar is complete, so the audit runs it. ADR 007 is amended.

Population. An audit of a real repository with no document handed over; a repository that plants its own security-pillar.json; an operator who names one; and an interpreter without secure-code-agent installed.

_security_delegate runs secure-code-agent through _runner.run — rule 7's single execution path — with the auditing interpreter (D144, D166) and a neutral working directory. Every report the tool can write is redirected to a temporary directory. Its append-only .secure-code/history.jsonl cannot be redirected without handing it a configuration from outside the tree, and secure-code-agent trusts an outside configuration to name executables inside the tree, so that override would launder repository content past the other tool's own boundary: the history is declared as this tool's sixth write instead, in server_info and the MCP instructions. An operator's --security-pillar document is used as given and the tool is not run again. A document the audited tree supplies is no longer read on its own authority: a repository may not report its own security posture in place of a measurement. secure-code-agent becomes this package's one runtime dependency; not installed, a rejected configuration or a timeout is the pillar's stated reason, and a missing install is a line in the environment work order.

Found running it on this machine: a private repository's secure-code-agent.json carries a _comment key that secure-code-agent 0.10.0 rejects, so its pillar reads "exited 2 without a pillar document" with that message. That is the repository's configuration, reported as the reason, not a fault here.

Closing tests: test_an_audit_runs_secure_code_agent_and_leaves_only_its_declared_history, test_a_pillar_document_the_tree_supplies_is_not_reported_as_the_measurement and test_a_missing_delegate_is_stated_with_its_remedy in tests/test_security_pillar_runs_and_shows.py. The last fails at the base by import rather than by assertion, because the module it patches did not exist.

Mutation: one of secure-code-agent's reports left unredirected (--output dropped) — a write, not the display that was reported — fails the declared-history test; a tree-supplied document still winning when present fails the planted-document test.

D178 — Closed: a documented scanner install broke the chat door, and the door's error called an installed package missing (High)

Found proving D177 through a fresh MCP server. maintainability-agent-mcp exited at startup: ImportError: cannot import name 'MCPServer', reported as "MCP support is not installed. Install with: pip install "maintainability-agent[mcp]"".

mcp was installed — at 1.23.3. Installing secure-code-agent's scanner extras into this package's environment, as the 3.7.0 docs/machine-setup.md told a reader to, pulled in njsscan and semgrep; semgrep pins mcp<2; pip uninstalled this package's mcp 2.0.0 to satisfy it. pip check reported nothing, because the mcp requirement lives in the mcp extra, which it does not inspect. The session already running kept working, so the break would have surfaced at the next restart — of this editor's chat door, and of any host sharing the environment.

Two defects. The documentation prescribed an install that breaks the product (P5's sibling for docs: an instruction with no basis in what actually works). And the startup message described an installed package as absent and printed, as its remedy, the requirement the conflicting package had just overridden (P8): the reader learned neither the version present nor what installed it.

Population. The three states mcp's metadata can be in when the import fails — absent, outside >=2,<3, and inside it but unimportable — at the function and at create_server, the seam the operator meets.

mcp_unavailable_message reads the installed version and the distributions that require mcp. Absent keeps the install instruction. Out of range names the version, the requirement and the dependents, and says to give that tool its own environment. In range reports the import error itself rather than a version it does not have. The setup page installs bandit on its own and semgrep and checkov from Homebrew, and states why the extras must not share this environment.

Closing tests: test_a_version_outside_the_range_names_it_and_who_required_it, test_a_version_inside_the_range_reports_the_import_error_itself and test_the_console_entry_raises_the_named_message in tests/test_mcp_start_failure_names_what_is_installed.py.

Mutation: the dependents found by a prefix test on the requirement string — the shortcut the first draft took — blames mcp-helper, a distribution the sweep plants that requires mcpx and not mcp, and fails the out-of-range test.

D179 — Closed: the security pillar recorded five critical findings and every skin showed none of them, and secure-code-agent's work order was deleted unread (High)

From the audit of 3.7.3 (7befdbd), run on this repository. secure-code-agent 0.12.1 recorded 5 critical, 1 high, 4 medium and 69 low findings. The pillar row read "unverified: not graded — no gates.require_scanners is declared, so no scanner set was asserted to have run", and the remediation prompt read "posture unverified, practice level 5, condition not graded". Neither said a finding existed. D176's fix for the ungraded cell replaced the posture note with the reason no grade was issued, and dropped the one fact a reader could act on in the same move.

The second half was in _security_delegate.py. 3.7.0 redirected every file secure-code-agent writes into a temporary directory, read back the pillar document, and let the directory be deleted with the tool's remediation prompt still in it: a located, constrained work order — 74 to fix, 5 to review on this repository — that the run had produced and nobody could see. ADR 007 §1 keeps security findings as that tool's work order; the handoff kept the grade and discarded the work order.

Population. Every state a measured security pillar can be in — each posture in POSTURE_NOTE, and ungraded — across the four surfaces that read the pillar: chat view, complete Markdown, HTML and the remediation prompt. The counts carry a severity name outside secure-code-agent's five.

pillar_cells appends the producer's findings_by_severity in both the graded and ungraded branches, so Markdown and HTML take it from the one function; the prompt's security line states it too. An empty count reads "no findings"; a document with no count prints nothing, rather than a zero it did not state. The delegate reads its own prompt.md before the directory goes; the report carries it as security_work_order beside the document it came with, and not without one. The complete Markdown and HTML reports print it whole, with its headings nested under the report's and quoted snippets untouched; the bounded chat view and the maintainability prompt say where it is and do not absorb it; the chat door returns it at the top level on every format, as D9 did for environment_work_order.

Closing tests: test_every_skin_shows_what_the_pillar_counted, test_the_work_order_the_delegate_wrote_survives_the_run and test_the_work_order_reaches_every_skin_and_the_chat_door in tests/test_security_findings_keep_their_handoff.py.

Mutation: counts rendered from secure-code-agent's five known severities only — the shape the producer's own report uses — drop "3 unrated", which the sweep plants outside that list, and fail every state; demoting every # line without tracking fences rewrites the quoted # a quoted comment as a heading and fails the chat-door test.

D180 — Closed: the prompt withheld a finding as a design decision while its copy-paste block told an agent to make the change (High)

From the same audit. examples/demo/expected-prompt.md — the demo's checked-in work order — gave each of its two duplicated blocks a standalone block reading "Task: remove the duplicated block. Make one small, reviewable change." The remediation prompt for the same run withheld both: "2 finding(s) need a design decision before code moves … Do not attempt them here." The per-item block exists to be pasted alone, so what an agent was authorised to do depended on which text it was handed.

prompt_items held two withholding rules — a Major Project, and a finding escalated for design review because it was fixed and came back — and prompt_body_lines knew neither. Every skin that renders a block called it: the bounded chat view, the complete Markdown report and the HTML report.

Population. Every work-order item, split by whether the bounded prompt hands it over, on each of the three skins that print blocks. The demo's withheld items are all Major Projects, so a handed item is escalated through design_review_candidates to reach the second rule.

withheld_reason is now the one rule: prompt_items hands over what it returns None for, and prompt_body_lines renders a withheld item as "Design decision needed, not a patch", with the reason and "Do not change code for this item", asking for the options and a stop. Every renderer passes the escalated fingerprints. Withheld items keep their blocks, because a person deciding on a deduplication needs to see it; the block now asks for that decision.

Closing tests: test_the_demo_major_projects_ask_for_a_decision_on_every_skin and test_an_escalated_finding_asks_for_a_decision_on_every_skin in tests/test_withheld_work_keeps_its_boundary.py.

Mutation: the block keyed on band alone — the rule the demo sample shows — authorises the escalated quick win on all three skins and fails the escalation test.

D181 — Closed: a full scan reported semantic findings from files the configuration excluded (Medium)

From the same audit. With tests/fixtures/ excluded, the scan read one file and the report still carried a design review candidate from tests/fixtures/semantic_ts/src/operations.ts, which the remediation prompt named. The semantic walk reads the whole tree. D174 held a changed-only run to its paths and left the full run unfiltered — the same class, closed for one mode.

Population. A tree carrying the semantic fixture at three places, its located findings derived from the walk, and an exclusion in each pattern form is_excluded accepts: a directory prefix, a glob and a ** glob.

_attach_semantics keeps a finding only when its source path is in the population the rest of the audit read — the files collect_metrics returned, with exclusions, changed-only paths, generated and vendored code, and asset directories already applied. One rule covers both modes; D174's own test still holds. The coverage counts are recomputed from the findings kept, so they cannot count what the report dropped.

Closing tests: test_a_full_run_keeps_no_semantic_finding_from_an_excluded_file in tests/test_semantic_findings_stay_inside_the_audited_files.py.

Mutation: filtering on the configured patterns as text prefixes — enough for the audit's tests/fixtures/ — keeps the findings under services/*/src/*.ts and **/beta/** and fails both glob cases.

D182 — Closed: the supported secure-code-agent release ignored reviewed paths: suppressions on every audit this tool runs (High)

Found closing D179. The pillar's five criticals were acknowledged in .scignore.yaml by a paths: entry, and all five still read as live. secure-code-agent 0.12.1 matched paths: globs against absolute finding paths, and _security_delegate always hands it an absolute root, so under the release this audit declared supported, every reviewed paths: suppression was ignored and its finding counted in the pillar every skin shows. 3.7.4 worked around it for this repository by rewriting the entry as file:. That fixed this repository's report. It left every other repository's paths: entries broken.

The defect was secure-code-agent's, and it was fixed there as its D20 in 0.12.2, released 2026-09-14. What stayed wrong here was the range: SUPPORTED_FLOOR still admitted 0.12.1, which writes the same schema v2 document and so passes every check this audit makes of the contract.

Reproduced on this repository, audited from a clone with the paths: entry restored:

secure-code-agent gitleaks.curl-auth-user findings suppressed
0.12.1 4 0
0.12.2 4 4

The floor is now 0.12.2. The requirement in the environment work order, the five CI pins, the README, CONTRIBUTING, the machine-setup page and ADR 007 say the same thing, and .scignore.yaml is back to paths: for both entries.

The floor, the install requirement and the CI pins stay in step through two guards that already existed and pass at the 3.7.4 base: test_the_supported_range_and_the_install_remedy_are_one_declaration and test_every_ci_pin_is_a_release_the_audit_runs in tests/test_security_delegate_versions_agree.py. They are what catch the second and third mutations below. They do not falsify this change.

Closing tests: test_an_unsupported_release_is_stated_not_run in tests/test_security_pillar_runs_and_shows.py, whose 0.12.1 case fails at the 3.7.4 floor.

Mutation: the floor left at (0, 12, 1) runs 0.12.1 and fails the new case; the floor raised with one CI pin left at ==0.12.1 fails the pin test; the floor raised with REQUIREMENT left at >=0.12.1 fails the one-declaration test.

D183 — Closed: an address outside the commit identity reached history through commit messages and file content (High)

The identity check read two fields, and the address came in through two others.

The authorship workflow required every commit's author and committer to be the repository's commit identity, and a history rewrite had removed the commits that failed it. Both were checked. Neither covered the message or the files: a personal address that must never be attributed to this project survived in Co-authored-by: trailers on five squash-merged commits, in the body of a sixth, and in the content of two tracked documentation and workflow files across 159 historical commits. Each release published in that window carried it in its source archive.

It was removed by rewriting every branch and tag, deleting the 47 releases whose tags GitHub will not let move, and deleting the CI runs whose logs printed it. None of that stops the next occurrence, which is this entry.

Two steps are added to the already-required authorship job, so they are enforced from the day they merge:

Closing tests: test_a_trailer_naming_another_address_fails, test_a_forbidden_term_in_a_message_fails_in_any_case, test_a_forbidden_term_added_to_a_file_fails_without_printing_it and test_the_forbidden_term_check_fails_closed_without_the_secret in tests/test_authorship_gates.py.

Mutation: the trailer check narrowed back to --no-merges passes every named case and lets a merge message carry an address, which is outside the sample those tests name; the term check reading only the diff and not the message fails the message case; dropping the empty-secret guard turns the unset secret into a clean pass and fails the fail-closed case.

D184 — Closed: the security pillar could not be graded, because the delegate config asserted no scanner set (Medium)

Coverage was complete and the grade was still withheld.

secure-code-agent releases a security grade only when the configuration names the scanners that must have run. This repository's delegate config declared fail_on_severity, fail_on_category, fail_on_new and max_unsuppressed, and never require_scanners. Every audit therefore reported a posture of unverified — not because anything was wrong with the code, and not because a scanner had failed, but because nothing had been asserted to have run. The one signal was a line in the delegate's own output: "grade withheld: no gates.require_scanners is declared, so no scanner set was asserted to have run".

It was read as the other half of the same symptom for some time. The other half was real and is fixed in secure-code-agent 0.12.5 (its D23): osv-scanner exits 128 with "No package sources found" on a repository with no lockfile, which that adapter mapped to a scanner failure, holding coverage at partial permanently. Fixing it took this repository's coverage to complete and left the grade withheld anyway, which is what isolated this defect from that one.

gates.require_scanners now names bandit, builtin_rules, pip_audit and njsscan. The set is deliberately the delegate's own required-scanners extra: those install from PyPI, so the runner that asserts them can have them. Requiring gitleaks, semgrep or trivy would fail every pull request, since they are binaries CI does not install; requiring osv_scanner would fail for the reason its own fix makes explicit — a repository with no package sources cannot satisfy a scanner that reads package sources.

The two halves are held together rather than separately: the job that runs the gate installs secure-code-agent[required-scanners], because naming a scanner the runner lacks converts complete coverage into a hard gate failure on the absence of a tool rather than on anything about the code.

Closing tests: test_the_delegate_config_asserts_a_scanner_set and test_the_job_that_runs_the_gate_installs_the_scanners_it_requires in tests/test_security_pillar_asserts_a_scanner_set.py.

Mutation: removing require_scanners from the config restores the withheld grade and fails the first test; installing the delegate in the gate job without its required-scanners extra passes the first and fails the second, which is the shape that would otherwise reach CI as a gate failure blamed on the code.

Answering "yes" and getting silence.

test_execution.requested true with no expected_commands.test in the user tier is a reachable state: setup records the consent and does not record the program. D147 and D152 make the refusal correct — the tree may say how it is tested, only the person may choose what this host executes — but the person is then left with a suite that never runs and nothing telling them why it is fixable.

Neither door said anything useful. The report's detail explained the refusal and named no remedy. The MCP gate's discovery line is guarded by run_tests_pending, which is true only when test_execution is absent — the config that predates the opt-in — so a config that answered the question read as asked-and-answered and got no line either.

This repository's own audit sat in that state. Every self-audit for weeks reported "the operator opted in to running the repository's test command; it did not run", and the testability reading was taken without a suite execution behind it.

The detail now names the remedy, and consented_without_command() gives the gate the predicate run_tests_pending cannot express, so the chat door offers reconfigure for this state as it already does for the other one.

Closing tests: test_the_report_detail_names_a_remedy, test_the_predicate_sees_consent_without_a_command, test_a_recorded_command_is_not_reported_as_missing and test_the_mcp_gate_offers_reconfigure_for_the_gap in tests/test_consent_without_command_has_a_remedy.py.

Mutation: restoring the detail string without the remedy sentence fails the first; making the predicate read the merged config rather than the user tier passes the second and fails the third, which is the D147 inversion it exists to refuse; removing the elif branch at the gate fails the fourth while the report-side tests still pass, which is the shape that let this survive.

D186 — Closed: the security pillar was held at B+ by findings that are what this tool is (Medium)

Sixty-two reports of a subprocess, all correct, all permanent.

D184 gave the pillar a scanner set to assert, so it could carry a grade. The grade it carried was 3.82 (B+), and reading what held it there found nothing to fix: 62 bandit findings for importing and calling subprocess, in a tool whose entire purpose is to invoke other analyzers, plus three for reading Checkstyle and JUnit XML this repository did not write, two for calling the SonarCloud API, and one for a seeded bootstrap in the calibration study.

Every one is a true report about an architecture that is not going to change. The instruments available were exclude_patterns and --skip, which delete the observation and leave no trace — the silence this tool's own _conformance detector counts as a defect when it finds NOSONAR in someone else's tree — or an annually expiring suppression per finding, which is a ritual re-approving a fact that has not changed.

secure-code-agent 0.12.6 added capabilities (its D24) and 0.12.7 made the stated reason print on the page (its D25). The delegate config now declares four, each with the sentence a reviewer reads to decide it is still true, and the findings stay in the report on their own axes: counted, listed, not scored. The summary discloses both numbers — 5.00 (A+) with the declarations and without declarations 3.82 (B+) beside it — so the movement is stated rather than silent.

Four bandit B105 findings in _masking.py are not declared. They are a name heuristic firing on the word token in a JavaScript tokenizer, which is a false positive and not an architectural shape, so they are acknowledged in .scignore.yaml with a reason and an expiry — the instrument that fits them.

Declaring also couples this repository to a delegate release. Below 0.12.6 secure-code-agent's strict unknown-key sweep refuses the whole configuration, so the audit does not run; 0.12.6 runs it and prints no reason under the declared axes. SUPPORTED_FLOOR moved from 0.12.2 to 0.12.7 for that reason, and the five CI pins with it.

Closing tests: test_every_declared_capability_is_one_the_delegate_defines, test_every_declaration_states_a_reason, test_declaring_capabilities_raised_the_supported_floor and test_no_ci_pin_predates_the_disclosing_release in tests/test_declared_capabilities_match_the_delegate.py. The gate on this repository passes at 0 scored findings with COMPLETE coverage, and secure-code-report.md prints each declaration's reason under its axis.

The known-name set in that file is copied from the delegate rather than imported: the two tools are independently releasable and this suite does not import secure-code-agent, which ADR 008 requires and the declared-imports sweep enforces. What guards the copy is the delegate itself, which refuses a capability name it does not define at config load — a drifted name fails the audit rather than passing quietly. The local set catches the typo in the suite instead of in CI.

Mutation: inventing a capability name the delegate does not define routes nothing while reading as a decision, and fails the first; a declaration with a token reason fails the second; leaving the floor at 0.12.2 fails the third while every other test in the file still passes, and is the state where this repository's own config stops its audit from running at all; leaving one pin at 0.12.6 fails the fourth — the findings route and no reason prints, which is the grade moved in silence that declaring was supposed to replace.

D187 — Closed: the full security record was uploaded to code scanning, so every assert in a test blocked the merge (Medium)

Seventeen threads, all of them assert.

Found trying to merge the pull request that closed D186. Every required check passed and the merge was refused: branch protection requires every review thread resolved, and GitHub Advanced Security had opened seventeen of them from the uploaded SARIF — one per assert in the three test files the branch added.

secure-code-agent writes two SARIF documents for exactly this reason (its D22, which records the same failure in its own repository: fourteen threads, twelve for assert in pytest). --sarif-output is the record — every result, a suppressed one carrying its suppressions entry and reason. Code scanning does not honour that array: it opens an alert for every result it is handed, which is why GitHub ships a separate action to dismiss them afterwards. --code-scanning-sarif-output is the same document without the suppressed results.

This repository uploaded the record. The test tree is reported-not-scored in the pillar and in the report, and that separation stopped at the one door where it decided whether a human could merge. It is not a one-off: it is the outcome for every pull request that adds a test file, permanently.

The audit now writes both documents, code scanning gets the filtered one, and the record is still kept as a build artifact. Nothing is hidden — the record, the JSON report, the Markdown report and the work order all still carry every finding. What changed is which document becomes somebody's blocking alert.

Closing tests: test_the_security_record_is_never_uploaded_to_code_scanning, test_the_filtered_document_is_what_is_uploaded, test_the_audit_asks_the_delegate_to_write_the_filtered_document and test_the_filtered_document_is_not_committed in tests/test_code_scanning_gets_alerts_not_the_record.py.

Mutation: restoring sarif_file: secure-code.sarif fails the first; removing the upload entirely passes the first and fails the second, which is why proving the record absent is not enough; dropping the --code-scanning-sarif-output flag while keeping the upload passes both and fails the third, and is the state where the upload step names a file nothing wrote; leaving the new document out of .gitignore fails the fourth and commits a per-run artifact.

D188 — Closed: the agent reported its own version from source, so a stale process could never notice it was stale (Medium)

"3.7.7", stated plainly, all day, with nothing marking it wrong.

config.VERSION is a source constant. Every surface reports it as agent_version, so the number is read from whatever code is loaded and is therefore always self-consistent: it cannot be wrong about itself. A server started before an upgrade answers every call with its own old version as fact, and a reader has no way to tell that from a correct answer.

That is what happened here on 2026-09-18. 3.7.8 shipped, the MCP server kept serving 3.7.7 from a process started the previous day, and it said so on every gate reply without comment. The audit that would have reported D184–D187 was running code that predated them.

Underneath it was a second, worse state. The venv actually serving MCP was an editable install whose dist-info froze at 1.8.0 while the source tree had moved to 3.7.8, and it carried secure-code-agent metadata at 0.2.0 against a supported floor of 0.12.7. --version reads the source and printed the right answer throughout.

This is not cosmetic, and the reason is this repository's own code: _security_delegate gates the delegate on metadata.version("secure-code-agent"). A frozen environment does not merely misreport a number — it refuses a correctly installed delegate and reports the Security pillar unmeasured while every other signal reads green. The audit was strict about the sibling's installed version and blind to its own.

_running_version closes the asymmetry. The installed distribution's metadata is a second opinion, written to disk by the installer and read fresh, so the two disagree exactly when something is out of date — and which side is ahead names the fault and therefore the remedy:

Reported on every surface that reports a version: the gate replies that run no audit (where this hid), the audit result, server_info, and the CLI on stderr before it prints anything else — because it is about whether to trust what the run is about to say.

Silent when the two agree, and silent when metadata cannot be read at all: a source checkout with nothing installed is a legitimate way to run this, and a check that fired on every one of those runs would be trained away inside a day and unread on the day it mattered.

Closing tests: test_agreement_reports_nothing, test_an_uninstalled_source_checkout_reports_nothing, test_a_stale_process_is_named_as_a_stale_process, test_a_frozen_editable_install_is_named_as_one, test_the_editable_remedy_names_what_else_reads_the_stale_number, test_an_unorderable_version_still_reports_the_disagreement, test_the_gate_reply_carries_the_drift, test_the_gate_reply_is_unchanged_when_versions_agree and test_server_info_states_the_drift_field in tests/test_the_agent_knows_when_it_is_stale.py.

Mutation: comparing the two versions for inequality without ordering them passes every mismatch test and fails the two that separate a stale process from a frozen editable install — the whole value of the check, since the remedies are opposite and one of them fixes nothing; returning a drift when metadata is unreadable fails the source-checkout test and produces the warning-on-every-run that gets ignored; dropping the version_drift call from _envelope leaves every unit test green while restoring exactly the surface that went stale unnoticed; removing the delegate sentence from the editable remedy passes the rest and leaves a reader believing a frozen dist-info is a display bug.

D189 — Closed: the setup questions could be asked but not answered, on every host that cannot be elicited (High)

Seven questions, asked twice, with nowhere to put the answers.

Found by a user reconfiguring this repository through the chat door. The tool returned setup_needed with its seven questions, they were put to him as structured choices, he answered all seven — and the configuration on disk was untouched. Calling again returned choice_needed, as though nothing had been asked. He answered eleven questions across two attempts and every one was discarded.

The mechanism. audit_repository_tool takes setup, annotated Annotated[ElicitationResult[Any], resolve(setup_resolver)]: an elicitation-resolved parameter the MCP SDK fills by asking the host. Being SDK-resolved, it is absent from the tool's published input schema, so no client can pass it. _mcp_setup's own docstring describes the degradation path — "a host that declines, or cannot elicit at all, gets the same questions back as data, so its own question UI can ask and call again" — and that path was never implemented. Calling again returns the same questions. The loop cannot close.

This is D185's shape again, one door over: a state a user can reach, be asked about, and have no way out of. D185 closed the morning of the same day.

Why review did not find it, including an auditor's. In source the path reads correctly: the function takes setup, _apply_call_consents reads it, apply_answers writes the file, and every line is right. The defect exists only in the difference between the function's signature and the schema published to a caller, which is a separate artifact. Reading the code cannot show it; only calling the tool as a client can.

The fix, and the class. setup_answers: dict[str, str] | None is an ordinary published parameter carrying the same answers to the same apply_answers, so an elicited host and a data host now travel one road. The legal names are derived from setup_questions rather than listed beside them, so a question added without a way to answer it is not expressible. A key no question asked for is refused rather than dropped, because apply_answers ignores names it does not know — right for a validated elicitation payload, wrong for an argument a host typed, where dept would persist the default depth and report success.

The gate is test_every_setup_question_has_a_submittable_name, which asserts against the caller-facing surface with the SDK-resolved parameters removed — the artifact the defect lived in, rather than the source that looked fine.

Closing tests: test_every_setup_question_has_a_submittable_name, test_the_submittable_names_are_derived_from_the_questions, test_the_tool_publishes_a_parameter_that_carries_setup_answers, test_the_elicitation_parameter_is_not_counted_as_an_answer_path, test_submitted_answers_reach_the_local_configuration, test_a_misspelled_answer_is_refused_rather_than_dropped and test_the_answers_a_host_is_shown_are_all_accepted in tests/test_every_question_can_be_answered.py.

Mutation: counting setup as an answer path makes the publishing test pass in exactly the state that shipped, which is why the SDK-resolved names are removed and a second test holds that removal; replacing the derived name set with a literal list passes every test here and reintroduces the drift the day a question is added; dropping _refuse_unknown_answers passes all seven and returns a success for a misspelled key that silently persisted a default; asserting the applied return value rather than the file on disk passes against the broken path too, since it reported success while writing nothing.

D190 — Closed: only the elicited host was told which setup stage it was in (Medium)

Answered stage one, which opened stage two, and was handed stage one again.

Setup is staged. economics: include opens the three labor rates; run_tests: yes opens the test command. The second stages exist so nobody is asked for a rate they do not want to give.

setup_schema knew that. The gate returning questions as data did not: it called setup_questions(load_config(None)) — no root, so no way to know the stage — and could only ever produce stage one. A host that cannot be elicited answered the seven, which set economic_context.requested, which opened stage two, and the next reply was the same seven questions. The rates were unreachable from that surface.

Found immediately after D189, by using the thing D189 fixed: the answers now landed, and the follow-up question never came.

This is D189's asymmetry one layer in. There the answers had nowhere to go; here they went somewhere and the next question never arrived. Both are one shape — the elicited path knew something the data path did not — and D189's gate did not catch this because it asked "can every question be answered", never "are the right questions being asked".

staged_questions is now the single stage decision. setup_schema renders it as an elicitation model and the gate returns it as data; two renderings, never two opinions.

Closing tests: test_both_paths_offer_the_same_questions (parametrised over the economics, suite and neither states), test_opting_into_economics_moves_the_data_path_to_the_rates, test_a_repository_needing_nothing_staged_is_asked_the_first_stage, test_the_gate_reply_carries_the_staged_questions and test_the_staged_questions_are_answerable in tests/test_both_setup_paths_ask_the_same_stage.py.

Mutation: testing parity in only one repository state passes against the broken code in two of the three, because stage one is what both paths produce when nothing is staged — which is why the parity test is parametrised over every staging state; asserting the data path only by agreement with the schema passes if both regress together, which is why the rates are also asserted positively; leaving the gate calling setup_questions directly passes every test that inspects the helper and fails the one that reads the reply a host receives.

D191 — Closed: the tool wrote a configuration its own published schema rejected (Medium)

Four blocks setup writes, none of them described.

maintainability-agent.schema.json is what an operator points their editor and their CI at. It sets additionalProperties: false and described ten blocks. First-run setup writes four more — presentation, history, test_execution and economic_context — so completing setup produced a file the tool's own contract called invalid, flagged on every line of it by any editor honouring the $schema link.

Found by the schema-fidelity suite the moment this repository's config gained those blocks. It had passed for the life of the schema because the only configuration it validated was this repository's own, which predated setup and carried none of them. A fixture that cannot reach the broken state is a test that cannot fail.

The gate is now the round trip rather than a list: run apply_answers and validate what lands on disk, across every answer combination and both staged second asks. A settings block added without a schema entry fails here instead of in somebody's editor.

Writing that gate immediately caught a second error — in the fix itself. The rates were assumed to persist as labor_low/labor_base/labor_high at the top of the block; they are actually nested under loaded_engineering_cost_per_hour, and requested is replaced rather than kept. A hand-written schema entry would have shipped that wrong and passed a list-based check.

Closing tests: test_a_completed_setup_validates_against_the_published_schema, test_every_block_setup_writes_is_described_by_the_schema, test_every_answer_combination_writes_a_valid_config (parametrised over the declined options) and test_the_staged_second_answers_also_validate in tests/test_setup_writes_a_valid_config.py. This repository's own config is already validated by the schema-fidelity suite, which is what caught this; duplicating that check here would have defended nothing and is deliberately absent.

Mutation: validating a hand-written fixture instead of the round trip passes with any schema that happens to match the fixture, which is exactly how this survived; testing only the all-yes answers misses the shapes a declined option writes; omitting the staged second call misses the nested rate block entirely, which is the shape the first attempt at this fix got wrong.

D192 — Closed: the setup instruction described a different reply than the one it shipped with (Low)

Setup "has not been set up", above three questions that exist because it was.

setup_instruction is the prose an agent relaying the questions reads. It was written once and served every stage. A stage-two reply — the three labor rates, reachable only by answering stage one — opened by saying setup had never happened, then recited the option list for default_format with no such question anywhere in the payload.

Neither costs an answer, so neither is a dead end. They cost the instruction its authority. It is the only thing telling a host not to invent questions of its own and not to answer on the user behalf, and a reader who finds it contradicting the payload beside it stops reading it — at which point the substituted-question failure it exists to prevent is back.

It also never named setup_answers. It said "call audit_repository again" and stopped, which was the whole truth available before D189 and is now a missing sentence: the host is told to call again and not told what to put in the call.

The text is derived from the questions in the reply. The opening names the stage, the presentation options appear only where default_format is actually asked, and every stage says how to submit.

Closing tests: test_a_staged_reply_does_not_claim_setup_never_happened, test_a_staged_reply_does_not_recite_the_presentation_options and test_every_stage_says_how_to_submit_the_answers (parametrised over all three stages) in tests/test_setup_instruction_describes_its_own_reply.py.

The first-stage wording is held separately, in tests/test_first_stage_setup_wording_holds.py. Those assertions pass at the base commit: they defend this change rather than prove it, and the file says so. Citing them here would make this entry look better attested than it is.

Mutation: dropping the presentation recital entirely passes the staged test and fails the first-stage one, which is the regression that would let a host offer two of the three presentations — the D26 failure that recital exists to prevent; naming the stage from the reconfigure flag rather than from the questions passes on a first run and restores the false opening on every staged reply.

D193 — Closed: submitting the answers was published, but not finished (Medium)

From an audit of 178a642, verified by tests driving a real client.

D189 published setup_answers and D190 staged the questions behind it. Both were checked with helpers called directly. Driving an actual MCP client through list_tools() and a full round trip found the path still unusable in three ways, and a fourth thing wrong beside it.

The rates never landed. The published parameter is a mapping of strings — what a JSON schema can say about a flat object a host fills in — and the rates are stored as numbers. "90" stayed a string, _is_bounds_only did not recognise the reply, the bounds stayed pending, and the host was asked the same question forever. The earlier tests passed floats straight to apply_answers and never crossed the published boundary where the conversion is owed.

An illegal value was accepted. apply_answers reads the vocabulary it knows and defaults the rest, which is right for an elicitation payload the SDK validated against a schema and wrong for an argument a host typed: depth: "nuclear" persisted moderate and reported success. A submission was silently answered as something the user did not choose. D189 refused unknown keys and never examined values.

Three surfaces said "call again" without saying how. SERVER_INSTRUCTIONS and both SKILL.md copies carried the sentence that was the whole truth before D189 and is now an omission. D192 fixed setup_instruction and stopped there, which is the same half-fix in a smaller frame.

coerce_submitted_answers does keys, values and types from one reading of published_questions, so a submission is judged against exactly what the host was offered. A question added anywhere is validated everywhere without a second list.

And the description came back inline. D192 moved the tool description to a module constant to keep _audit_tool_for inside the function-length gate. The description is the published contract and the test that holds it reads the source, so a constant is a second text to drift from the first. The closure body moves to _run_audit_tool instead: the gate is satisfied by moving code, not by separating a contract from the thing it describes.

Closing tests: test_submitted_economics_and_test_answers_advance_the_derived_stages, test_host_submissions_refuse_values_outside_the_published_questions (parametrised over depth and default_format) and test_every_call_again_instruction_names_the_public_answer_path in tests/test_setup_answers_are_the_same_setup.py, written against the published surface rather than the helpers behind it.

The fourth test in that suite, which asserts list_tools() serves setup_answers and none of the private resolver parameters, guards D189 and passes at this entry's base. It is kept — the defect it holds is invisible in the source, since the function has always taken a setup parameter — and lives in tests/test_list_tools_publishes_the_answer_path.py with its assertions unchanged, so it is not counted as evidence for this entry.

Mutation: validating submissions against a hand-written vocabulary passes every value test and drifts the day a question changes its options, which is the second-source-of-truth shape D189 and D190 were both instances of; refusing an illegal value after apply_answers has run leaves the file written, which is why the check happens before anything is persisted; keeping the tool description in a module constant passes a test that reads __doc__ and fails the one that reads the source, and the source is what a reviewer checks.

D194 — Closed: an agent could serve a presentation the user did not choose, and nothing said so (Medium)

presentation.format = "html" in the config; format: "json" in the result; no key connecting the two.

Found by the operator asking where his HTML report was. The relaying agent had passed format="json" for its own convenience while telling him it was running his persisted default, and the result gave him no way to catch it: format names what was produced and nothing names what was chosen.

The override itself is correct and stays. CI asks for json and must receive json; the tool has always documented format as a per-call value that beats the persisted one. What was wrong is that it beat it silently, which makes "the user asked for json" and "the agent preferred json" the same result.

This is D26's failure with the report produced instead of withheld. There a user never saw the html report because the question was handed over as data and never asked; here the question was asked, answered, and the answer overridden on the way out.

It was also the only divergence this tool did not disclose. Coverage says reported-not-scored and why. Pillars say not-measured and why. The environment work order names what could not run. The delegate prints the score its declarations moved. Presentation was the gap, and presentation is the one an agent can widen on its own.

presentation_override: {requested, persisted} appears only when a call names a format that differs from the recorded one, so it stays a signal rather than a field on every reply.

Closing tests: test_a_different_format_than_the_user_chose_is_disclosed and test_every_presentation_discloses_the_same_way (parametrised over every presentation that is not the recorded one) in tests/test_a_format_override_is_disclosed.py.

The silent cases — an honoured preference, an unset call, a repository that recorded no choice, and the override still taking effect — are held by tests/test_a_matching_format_stays_silent.py. They pass at this entry's base and are not cited: they defend the change rather than prove it.

Mutation: refusing the override instead of disclosing it breaks the CI door and fails the second test, which is why disclosure and not refusal is the fix; emitting the key on every run passes the disclosure tests and fails the three that require silence, and is the always-on alarm nobody reads; comparing against the chat fallback rather than the persisted value reports an override on every unconfigured repository, where there is no choice to displace.

D196 — Closed: the audit said which program was configured, never which one ran (Medium)

One configuration, two surfaces, opposite answers, and nothing saying why.

Through the CLI the consented pytest ran 2,908 tests and measured 96% coverage. Through the MCP server the same command exited 3, the suite did not run, and test_effectiveness was null — so one door graded test effectiveness and the other could not, from the same config on the same machine in the same minute.

The mechanism is locate, and it is working as designed. It searches the agent's own script directory before PATH so a virtualenv finds the analyzers installed beside it (D142). The consequence is that resolution depends on which interpreter is running the agent. The MCP server here was a venv console script invoked by a foreign interpreter, so sys.executable was that interpreter and locate("pytest") returned ~/Library/Python/3.11/bin/pytest — a different environment's pytest, which could not import the project. The CLI, running under the venv's own interpreter, resolved .venv/bin/pytest.

Nothing in either result showed that. command is the name the person consented to; what the host executed was never reported, so the two runs looked identical and disagreed. Finding it took six probes and two wrong diagnoses from me — a resolved path in the result would have made it one look.

This is D194's shape. The tool substituted something correctly and by design, and did not say what it substituted. resolved_program is now carried on every ToolResult that reached an exec, and surfaced in test_suite beside the configured command.

The foreign-interpreter launch is an environment fault and is fixed separately on the operator's machine. This entry is about the audit being unable to tell anyone it happened.

Closing tests: test_a_run_reports_the_program_it_resolved, test_an_unresolvable_program_is_reported_as_given, test_the_field_defaults_to_none_for_results_that_never_executed, test_the_suite_result_names_the_program and test_a_suite_that_never_ran_still_carries_the_key in tests/test_the_suite_names_the_program_it_ran.py.

Mutation: reporting invocation.argv[0] rather than the resolved path satisfies "the result names a program" and reports the bare name that was already there, which is the state this entry describes; omitting the key on the paths that never reached an exec makes a consumer guess whether the field is missing or the program unresolved; setting it only on the success path leaves the failing run — the one a reader is investigating — silent about what it tried to execute.

D197 — Closed: the HTML report quoted the security work order as Markdown source (Medium)

An operator chose HTML and got ## headings inside the page.

complete_html wrapped the delegate's work order in <pre> and its docstring said so: "the same text, preformatted". In a Markdown report that is invisible. In the HTML report — the presentation a person chooses when they want something readable — it is a hundred lines of raw source in a monospace box, headings as hashes, bold as asterisks, in a page where everything else is rendered.

Reported by the operator on sight, which is the only way it could have been found: every test asserted the text was carried, and it was.

The fix is not a Markdown parser. Prose is the one thing this tool cannot re-present, and parsing another tool's prose to draw it would be worse than the symptom. secure-code-agent now emits the same work order as facts (its D27), --work-order-json carries them, and the HTML draws them.

Still the delegate's work order. ADR 007 §1 says this tool does not re-rank, merge or reword security findings, so the data is drawn in the delegate's own tiers, order and counts, with its caps and its omitted number. A different presentation of the same work, never a different opinion about it.

An older delegate is still readable. No data means the Markdown is preformatted exactly as before — the honest rendering of prose, and better than a blank section. The floor moves to 0.12.8 because that is where the promise is kept, not because anything crashes below it.

Writing the ingestion surfaced a second error in the fix itself: the work-order JSON was first read with read_produced, which is the pillar reader and refuses any document lacking the pillar's own schema key. It returned None for a file written correctly, and the report fell back to quoting Markdown with nothing saying why — the same silent-fallback shape this register keeps recording. It has its own reader now, checking its own contract.

Closing tests: test_markdown_syntax_does_not_reach_the_page, test_a_finding_is_rendered_as_elements, test_the_code_snippet_stays_preformatted, test_the_citation_is_drawn, test_the_delegate_is_named_as_the_author, test_the_counts_are_the_delegate_s_own, test_a_capped_tier_says_how_many_are_missing, test_a_review_finding_shows_why_it_was_demoted, and test_without_data_the_markdown_is_still_carried in tests/test_the_security_work_order_is_drawn.py.

The empty case — a report with no work order renders no section — is held by tests/test_an_absent_work_order_renders_nothing.py. It passes at this entry's base and is not cited: it defends the change rather than proving it.

Mutation: asserting only that the findings appear passes against the <pre> that shipped, since the text was always there — which is why the first test asserts the Markdown syntax is absent; dropping the no-data path renders a blank section for a supported older release; re-counting the findings here instead of printing the delegate's counts passes every rendering test and makes this tool a second opinion on security, which ADR 007 §1 forbids.

D198 — Closed: the version was written in five places, and a release failed because one was missed (Medium)

The most coupled pair in this repository's own audit, and it was a copy.

change_coupling scored 2.0, the joint-lowest aspect. The top pair was __init__.py and config.py at 64 co-changes, 0.985 confidence — higher than any pair of modules that actually depend on each other. They were coupled because every release edited both, along with pyproject.toml and two documents: five files to change one number.

That is not a tidiness complaint. v3.7.9 was tagged and failed its release because four of the five were updated and the release plan's row was not. And secure-code-agent shipped a wheel stamped with one version while every SARIF document, JSON report and --version it produced carried another, because its pyproject.toml held a second copy that had drifted. PyPI is immutable, so that release stayed wrong.

The obvious fix is a test asserting the copies agree. That is the wrong fix, and this project's sibling wrote down why: such a test polices the duplication rather than removing it, and leaves the failure mode in place for the day someone adds a sixth copy.

One literal. maintainability_audit.__version__ holds it. config.VERSION re-exports that object rather than restating it — is, not ==, because an equal copy is exactly what was removed. pyproject.toml declares dynamic = ["version"] and reads the attribute, so the wheel cannot be stamped with anything else.

The two documents that also carry the number — the README header and the release plan's "Last tagged version" row — stay prose. They are already gated by their own tests, and they are now the only places a human types it.

D85's agreement check was retired with its premise rather than left passing vacuously; that entry is amended above to say so.

The first attempt broke the release it was fixing. release.yml compares the tag against the packaged version by reading pyproject's static version key — one of the copies this entry removed. v3.7.17 was tagged and failed eleven seconds in with KeyError: 'version'. The gate protecting against a mis-stamped release was itself reading a duplicate, which is the same defect one level up. It reads the literal now.

Closing tests: test_config_reexports_rather_than_duplicating, test_config_holds_no_version_literal, test_pyproject_derives_the_version, test_the_dynamic_version_points_at_the_package, test_only_one_module_writes_the_number and test_the_release_gate_reads_the_single_source in tests/test_the_version_has_one_source.py.

Mutation: asserting VERSION == __version__ rather than is passes with the copy restored, which is the check this replaces; re-exporting while also keeping a literal in config.py passes the identity test and fails the literal sweep; declaring dynamic while pointing the attribute at a different module passes the dynamic test and fails the one that names the package; a sweep over a hand-typed list of modules misses the sixth holder, which is the shape that produced this entry.

D199 — Closed: the chat view reported a suite-derived value and never said the suite had run (Medium)

test effectiveness: not measurable, for a suite that ran and failed.

D196 made the audit record which program it executed. The skins then disagreed about it: the complete Markdown and HTML named the resolved program, the bounded chat view named nothing — because it never rendered the test suite at all.

That is not a missing section. The chat view still prints test effectiveness and a testability score in its tables, so a reader was given a value and no way to recover what produced it. P8's violation is stated in exactly those words: "a reported value with no attributable source."

Worse than absent: wrong. A suite that ran and failed was reported as not measurable. It was measured. The measurement was a failure, and the reader was told no measurement existed.

This is D176 again, one artifact over. That entry put the pillars into the chat view because "the chat view printed nothing about security at all, measured or not, and a missing pillar read exactly like a clean one". A missing suite read exactly like one that never ran.

The claim was already in the source, and false. test_suite_lines is documented as skin-agnostic, and its docstring said "both skins render these same sentences, so a failed suite reads as failed everywhere". The chat view never called it. Anyone reading that function had been told the case was handled, which is why the gap survived D196 and this register.

The fix is the one ADR 011 already prescribes and D176 already used: render through the same function the complete report uses, so the skins cannot disagree. Four lines in a bounded view, which is what D160 bounded — volume, never attribution.

Closing test: test_every_test_suite_skin_names_the_resolved_program_not_just_command in tests/test_the_suite_names_the_program_it_ran.py, written against the rendered skins rather than the helper behind them.

The module-style resolution test in that file is not cited: D196 already made python3 -m pytest resolve its interpreter, so it passes at this entry's base and defends this change rather than proving it. It is marked as covering existing behaviour and kept, because a command whose program differs from its argv is the case a renderer is likeliest to get wrong.

Mutation: printing the resolved program in test_suite_lines alone — the literal instruction this cycle began with — passes every assertion about that function and leaves the chat view exactly as broken, because it does not call it; adding a pointer line instead of the sentences keeps the view bounded and still withholds what examined the value, which is the P8 violation rather than its symptom; correcting the docstring without rendering the section leaves a true sentence describing code that does not do it.

D200 — Closed: 330 findings asked for a voice this project does not write in, and a release chore nobody could remember (Low)

Two findings from reading this repository's own audit, closed together because both are about what a measurement is worth rather than what it says.

D401 was 330 of 566 pydocstyle findings. "First line should be in imperative mood" — every one asking for """Returns the score.""" to become """Return the score.""".

The docstrings here are deliberately not in that voice. They are narrative, and most exist to carry a defect's reasoning where the code that caused it lives: "Why each axis sits outside the score", "The delegate's own remediation prompt — its work order". Complying would flatten the sentences this project uses to explain itself, which trades the thing being measured for the measurement. It is also style rather than maintainability: a docstring's mood says nothing about whether code survives change.

Turned off in [tool.pydocstyle] with the reasoning beside it, rather than suppressed per finding — 330 acknowledgements with expiry dates would be a ritual, and the decision is about the whole repository's voice rather than any line. Every other rule stays on: missing docstrings, malformed summaries and bad quoting are still reported, which is the conformance half interrogate does not measure. Analyzer findings fell 832 to 502 and the documentation aspect did not move from 5.0, which is the evidence that nothing being measured was lost.

The version could not be reduced to one place in prose. D198 gave the number a single home in code. Four documents still have to state it, and each was guarded after going stale: the README's version line sat at 1.0.0 through five releases, and its Action example pinned v1.0.0 from 1.0.1 to 3.7.5 while action.yml runs pip install -e "$GITHUB_ACTION_PATH" — so that pin is what a reader installs.

Those guards are right and stay. What was wrong is that a person typed the number into each one: v3.7.9 was tagged and failed its release because the release plan's row was missed, and that row's check only fires once the tag exists, which is after anyone would have noticed. tools/stamp_version.py writes all four from the single source.

It does not improve change_coupling, and says so in its own docstring. Those files still move together because they must. This removes the chore, not the coupling — and the distinction matters, because the metric cannot tell enforced consistency from duplication, and this repository has both. The top six coupled pairs are the version, which is duplication; the next several are architecture.md with _architecture_layers.py at confidence 1.000, which is a gate working exactly as designed.

The first version of the tool rewrote this repository's history. Its SECURITY.md rule matched any N.N.x, including the sentence recording that an audit "found this table still naming 0.1.x eight release lines later" — the evidence for the entry explaining why that row is guarded at all. Caught by reading the diff, not by the tool. The rules are anchored to the table rows now and a test holds it.

Closing tests: test_the_tree_is_already_stamped and test_a_document_that_stops_matching_is_refused_not_skipped in tests/test_version_stamping.py.

tests/test_stamped_documents_stay_honest.py holds the two properties that pass at this entry's base — the documents are current, and the sentence recording why the table is guarded is intact. They defend the stamping tool rather than prove it, and are not cited.

Mutation: stamping the documents it can match and reporting success skips the one that drifted, which is exactly how v3.7.9 was tagged — so a rule that matches nothing exits non-zero and names the pattern; an unanchored SECURITY.md pattern passes every currency test and silently edits prose about past releases; suppressing D401 per finding rather than in configuration passes the same audit and leaves 330 expiring acknowledgements to renew.

D201 — Closed: a presentation the tool builds, validates and documents was offered by no question (Medium)

--format json has been accepted on both doors for eleven releases. The CLI parser lists it in --format's choices, docs/cli.md documents it, and the MCP tool validates format="json" and returns the report dictionary for it. Every surface that asks offered three options:

So a person driving either door could only reach it by finding a flag in the CLI reference. That is the D150 shape — a question that exists and is never put — one option over rather than two questions over.

ADR 011 made it invisible to review as well. It decided "three user-facing skins" and never mentioned json in any of its 87 lines, so a reader checking whether the omission was deliberate found silence and could read it either way. It was read both ways in a single afternoon: called a defect, withdrawn as a recorded decision, then decided by the operator — "since it can already be built, may as well be a TTY option". The ADR now carries that amendment, and the reason given for excluding it (that json is the dictionary the others render rather than a rendering) is recorded as true and insufficient.

The fix is one tuple, not four edits. PRESENTATIONS gains json in fourth place; the MCP question's options and the MCP door's format validator now read it instead of restating it, which removes the two copies that let the offered set and the accepted set disagree. chat stays what Enter selects. At the terminal json behaves like chat and not like the file skins: it goes to stdout, because a person choosing it is about to pipe it.

Closing tests: test_every_format_the_cli_accepts_is_offered_at_the_question and test_choosing_json_at_the_terminal_renders_json in tests/test_every_built_presentation_is_offered.py. The population is the argument parser's own --format choices, read off the parser, so a sixth format accepted and not offered fails the same way.

tests/test_the_presentation_question_stays_answerable.py holds three properties that pass at this entry's base and are not cited: the prompt string names every option, every option the question offers is one a door accepts, and typing any of them returns it. They read the population from PRESENTATIONS, so at the base they held over three words and here over four — a check that adapts to the set cannot falsify a change to the set.

Mutation: adding json to the tuple and leaving the resolver alone passes the question tests and renders Markdown, because args.format or "markdown" answers a fourth choice with the second — the two file skins are immune only because they set a path; restating the options in _mcp_setup rather than importing them passes every test that reads PRESENTATIONS while the two doors offer different sets, which is the state this entry found; and asserting the three current words rather than deriving the population from argparse passes today and says nothing about the next format built.

D202 — Closed by decision: the hostile-audit brief is an unfinished feature, not a defect (Low)

--hostile-prompt-output and the maintainability-hostile-audit MCP prompt emit a 52-line brief seeded from a real run: the commit SHA, which analyzers ran, failed and were not applicable, the concepts nothing measured, a P1–P8 table with a falsification shape for each promise, and a five-point audit contract. It is deterministic and it is honest about being a seed — ADR 013 is explicit that the core seeds a hostile audit and never performs one. src/maintainability_audit/_hostile_prompt.py contains no network call, no model call and no import of either; build_report, _derive and the scorer never reference it; nothing in the audit path reaches it.

What is wrong is everything around it.

  1. It is in no document a user reads. README: no mention. Both SKILL.md copies: no mention. docs/product-intent.md discusses the adversarial audit practice twice and never records that this tool emits a brief for it. ADR 013 is the only place it exists, which is the same failure mode D201 records one file over.
  2. Its results reach nothing. Whatever an LLM concludes from the brief stays in that chat. No skin renders it, no field carries it, the history record does not know it happened. A deterministic tool whose most adversarial output is unattributable to any report is at least in tension with P8, even though the brief itself states its own provenance.
  3. It is an unfinished feature, not a defect. The seam was built and the half that closes the loop was not.

Decided 2026-09-21 by the operator: this is an unfinished feature. Recorded as a decision rather than left open, because an open entry naming a gap is an instruction to close it — and leaving it open is what caused the decision to be asked for twice and re-presented as a pending question a third time after it had already been given.

What follows from that: the three items above are not a defect queue. Nothing here is wrong in the arithmetic or in any rendered report, and the brief cannot influence a grade because no code path connects the emitter to the scorer. The work it implies — documenting the emitter, or carrying its results into the report — belongs to whatever the agentic audit feature turns out to be, and that is a product increment rather than a bug fix. No audit should re-derive this entry as an open defect from the P8 sentence that raised it.

Required behaviour: ADR 013 (the core seeds, never performs) and P8 (every reported value names what produced it). Neither is violated by the emitter; both are unserved by its absence from the record.

Mutation: whatever closes this, the check has to derive the surfaces a user reads from the shipped set rather than name them, because the instance in hand is three files (README, both SKILL.md copies) and the claim is about every document that describes what this tool emits — the exact shape thirty entries above this one got wrong. A check written from the instance passes while docs/product-intent.md, which discusses the practice twice and the emitter never, goes on saying nothing.

D203 — Closed: the written release process described the chore that had just been removed (Medium)

D198 reduced the version to one literal. D200 stamped the four documents that have to state it. Neither touched the instructions.

release.yml's header said "bump the version in pyproject.toml + __init__.py + config.py". CONTRIBUTING.md said "bump the version in all three places" and listed the same three, naming config.py VERSION — a constant D198 had deleted. Neither mentioned tools/stamp_version.py, which is the step that replaced the chore they were describing. CONTRIBUTING.md also told the releaser to cut the GitHub Release by hand, which a job has done automatically and idempotently since well before this.

Following the documented process reintroduces the exact copies D198 removed, and the suite then refuses the result. That is worse than a stale comment: the releaser arrives at a red build caused by the documentation, and the first thing anyone does with a test that fails on correct-looking work is suspect the test. Instructions that fight the gates are how a correct gate gets weakened.

Found by an audit of 81caf1b, which also observed that the stamp tool "is not a release.yml step". Literally true and immaterial: the release build runs the whole suite against the installed wheel, and test_the_tree_is_already_stamped is in it. The enforcement was never missing. What was missing was any document that said so.

Closing tests: test_no_document_sends_a_releaser_to_a_file_the_version_left and test_every_release_instruction_names_the_stamping_step in tests/test_the_release_process_describes_itself.py. The population is every document that tells a releaser to annotate and push a version tag, found by searching the tree; the forbidden filenames are the copies D198 removed.

tests/test_the_release_record_stays_well_formed.py holds the positive half — that a release instruction names the one file a person types the version into — and is not cited, because it passes at this entry's base: both documents already named __init__.py. They named it alongside the two files the version had left, which is the defect, and saying where not to write it is only half an instruction.

Mutation: the population was first derived from the phrase "bump the version", which pulled in docs/migration-1.0.md — a sentence about a report's schema_version that has nothing to do with releasing — and the fix for that false positive is the shape of the real risk here: a check keyed to a phrase rather than to the act. Naming release.yml and CONTRIBUTING.md by hand passes today and says nothing about the third document, which is how this defect reached two files in the first place; and asserting only that the wrong filenames are absent passes on a document that names no file at all, which is why the one writer and the stamping step are asserted positively.

D204 — Closed: twelve consecutive releases published notes that said nothing (Medium)

The GitHub Release job extracts this version's ## X.Y.Z section from CHANGELOG.md and, finding none, writes Release X.Y.Z. and publishes that as the entire release notes.

CHANGELOG.md's last entry was 3.7.7. Tags 3.7.8 through 3.7.19 went out after it — twelve releases, each one publishing that single sentence to everybody watching the repository, including the three that closed Mediums in the security pillar and the one that changed what the MCP door accepts.

Nothing failed, and nothing could: an absent section and an intentionally terse one are the same thing to awk, and a substituted placeholder is indistinguishable from a deliberate one-line note from outside. The fallback is right for a re-pointed or recovery tag. It had quietly become the normal path.

The twelve sections are backfilled from the register and the release commits, and the check is placed in the ordinary suite rather than in the publishing job: by the time that job could notice, the tag exists, which is the same "after anyone would have noticed" that cost v2.11.0 a build (D124) and v3.7.9 its release (D200).

Closing tests: test_the_shipping_version_has_a_changelog_section and test_the_section_is_not_empty in tests/test_the_changelog_covers_the_shipping_version.py. The version is read from the package, so the check follows every bump without being edited.

tests/test_the_release_record_stays_well_formed.py also holds that no version is documented twice, and is not cited: it passes at this entry's base, where only seven versions were documented and none repeated. It is held because a duplicate heading publishes whichever came first and drops the other, and a backfill adding twelve sections under an existing heading is exactly how one gets written.

Mutation: asserting the section exists passes on a heading with nothing under it, which extracts to an empty notes.md and trips the same fallback — so the body is asserted separately; and asserting against a version named in the test rather than read from the package passes at the moment it is written and never again, which is the failure mode of every one of the twelve entries this backfills.

D205 — Closed: two version checks verified a proxy for what they claimed (Medium)

The gate's comment claimed something reading a file cannot do. It said "a tag is checked against the literal the wheel will actually carry". It reads src/maintainability_audit/__init__.py in the tree, before anything is built. That is a good gate — it fails in seconds rather than after an upload PyPI will not let anyone replace — but it is a check on the source, and the artifact is the thing that ships. The build step then installed the wheel, printed maintainability-agent --version, and compared it to nothing.

This is the sibling project's failure named in D198's own entry: secure-code-agent shipped a wheel stamped with one version while every report it produced carried another. test_the_dynamic_version_points_at_the_package holds the setuptools attribute; it does not hold the built metadata. Nothing did. The built wheel is now compared to the tag, and each comment says what its own check covers.

And the one-writer test searched for the current version's characters. test_only_one_module_writes_the_number swept every module for the string __version__ currently holds, which makes it blind in exactly the direction that matters: a module still carrying VERSION = "3.7.16" after the package moved to 3.7.20 is the drift the test exists to catch, and a substring search for "3.7.20" cannot see it. The check passed because the copy was stale. It now matches the shape of a version assignment — a module-level name containing "version" bound to a dotted number — so a second writer is caught whether or not it agrees with the first.

Both halves are one mechanism, which is why they close together: a check that verifies something adjacent to its claim, and reports on the adjacent thing.

Closing test: test_the_release_build_compares_the_artifact_to_the_tag in tests/test_the_version_has_one_source.py. It asserts against the workflow text, the way the existing gate check in that file already does, because the step runs in CI where no test does and what it is told to compare is the only thing checkable from here — and it asserts the comparison fails the build, because printing the number without comparing it is precisely what was there.

The strengthened sweep in that same file is deliberately not cited, and the reason is the finding itself. It cannot be proven by reverting the change, because the tree contains no stale copy to catch: a revert proof runs against a tree where the weak check and the strong check agree. The old check could only ever see a copy that matched the current version, so the only evidence it was weak is evidence it could not have produced. Mutation is the proof, below.

Mutation: planting RUBRIC_VERSION = "3.7.16" in _bands.py — a module neither version of the sweep names — passes the old search and fails the new one, and it is a stale literal rather than a fresh one precisely because a fresh copy was the only kind the old check could see. Separately, asserting that the build step merely prints the wheel's version passes on the code this entry found, which printed it and compared nothing.

D206 — Closed: a pillar's condition was a mean over an undisclosed subset of its aspects (Medium)

_condition averages the aspects that carried a number. That is the only honest arithmetic — a None is not a zero, and inventing one would be worse — and it means an unmeasured aspect is dropped rather than counted.

Maintainability declares seven aspects. Scored, with one bad one, they average 4.43. Withhold that one aspect and the same pillar reads 5.0, in a row that still lists all seven and says nothing. Both printed identically.

P3 is "withholding evidence cannot improve the reported grade", falsified by any input whose removal raises the reported field. P8 is "every report states what examined it", falsified by a reported value with no attributable source. This was both, in one cell.

The overall grade was already defended and the condition was not. _grade_on_the_floor prices unknowns at 0 and bands from the floor, so concealment is monotonically unprofitable there — which is why the audit's phrasing, "omitting analyzers can raise the grade", is right about the pillar reading and not about the graded field. Worth saying precisely, because the two have different fixes.

Flooring the condition was considered and rejected. ADR 007 defines it as the mean of measured aspects, and a delegated pillar's condition arrives already computed by the tool that owns it. Flooring one side would put two incomparable numbers in the same column, which is a worse lie than the one being fixed. Disclosure is the defence: every entry carries condition_coverage, and pillar_cells — already the one place both skins render a pillar row — names how many of how many aspects produced the number and which are missing.

"Could not measure" and "nothing to measure" are named separately. The scorer has always drawn that line for the grade, and the document kept only the consequence: a reader had to subtract the blocker text from the aspect layer to recover which was which, which is P8's second falsifier — a run whose coverage cannot be recovered from its output. score.not_applicable publishes it, additive and optional like analyzer_scored_dimensions before it, so no schema version moves.

One duplication fell out of it: SCORE_V2_FIELDS in tests/test_verified_grade.py was a second hand-typed copy of test_stage8_contract.CANONICAL, so one additive field had to be written into two lists that nothing compared. It is derived now — the difference between them is exactly the two fields stage 5 adds.

Closing tests: test_every_partial_condition_says_how_many_aspects_made_it, test_missing_evidence_and_a_resolved_absence_read_differently and test_withholding_the_worst_aspect_raises_the_number_and_says_so in tests/test_the_condition_says_what_it_measured.py. The population is PILLARS and each pillar's own declared aspects, read from the module that defines them, and the sweep withholds every aspect of every in-scope pillar one at a time rather than the one that motivated the entry.

tests/test_the_pillar_row_reads_cleanly.py holds that a complete condition carries no coverage note, and is not cited: it passes at this entry's base, where no row carried one. It is held because the obvious wrong version of this fix — annotate every row — satisfies every cited assertion above and teaches a reader to skip the sentence, which costs the disclosure the thing it was added for.

Mutation: the instance in hand is change_coupling under maintainability, and a check written from it passes while readability and testability say nothing — so the sweep is over every aspect of every in-scope pillar, and a sixth pillar is in the population the moment it is declared. Annotating every row rather than only partial ones passes the disclosure assertion and teaches a reader to skip the sentence, so the complete case is asserted silent. Printing one count for both kinds of absence passes every count assertion while an unknown reads as "nothing to measure", which is the collapse that makes silence read as fine.

D207 — Closed: the prompt forbade a change and then listed its target as work (Medium)

One prompt said two things. _withheld_paragraph prints "Not in scope for this change: N finding(s) need a design decision before code moves … Do not attempt them here." prompt_escalation_note prints that the escalated findings are deliberately excluded. Then prompt_focus_sections listed those same targets under "to inspect first", "to verify" and "to inspect".

withheld_reason is the one rule and it has two clauses — a Major Project, and a finding fixed before that came back. The focus lists honoured only the second, and only on three of their seven categories: hotspots, large files, risk findings. Duplicate blocks, near-duplicates, dead code and competing libraries were filtered by nothing at all, and no category honoured the Major Project clause.

This is D180's "one run authorising and forbidding the same change" on a different axis. It had already been fixed once — the audit of e88b429 found it in the risk-pattern list and the comment left behind says "the rule was enforced on every focus category except this one", which was true of the escalation clause and false of the whole rule. Written from the instance, not from the claim: the thirty-first instance of the register's own headline failure, in the file that fixed the thirtieth.

The fix is one rule, one locator, every category. withheld_targets reads every work-order item back through withheld_reason and emits one key each; _locate gained the competing-libraries shape, whose location is the minority package's example file, so both texts place a finding the same way.

Two keys, and they are not interchangeable. An identified finding is matched by identity, an unidentified one by location, and the fallback between them is wrong in both directions — which took two wrong attempts to establish. Falling back from identity to location let one class's withheld finding suppress another class's listed one: two huge overloads share two.py, and the fixture holding them also produces an unidentified duplication finding at one of their lines, so test_escalating_one_overload_does_not_hide_the_other failed for a reason it was not written for. Checking identity alone was wrong the other way only in a fixture that gave a risk-pattern item no fingerprint — a shape _items_from_counted never produces, because risk patterns are "the one class here the report gives a stable identity to". The fixture was corrected, not the rule.

Closing test: test_no_withheld_target_is_listed_as_work in tests/test_the_prompt_does_not_take_back_what_it_withheld.py. The population is every item the report's own work order marks withheld, read back through withheld_reason rather than listed here, and the report carries one finding of every class the focus sections render.

tests/test_the_prompt_keeps_listing_what_it_did_not_withhold.py holds three properties that pass at this entry's base and are not cited: that a run withholding nothing still lists everything, that the older escalation clause survives the rewrite, and that the fixture has not quietly lost a class. The first is the important one — the obvious wrong version of this fix is a filter that is really a mute button, and such a filter satisfies the cited test, which only ever asks whether withheld targets are absent.

Mutation: the claim-derived sweep caught two categories the first version of this fix missed — competing libraries, whose findings _locate could not place at all, and risk patterns, whose identity was being compared against an item keyed by location — so a check written from the duplicate-block instance would have shipped a fix that was still wrong for two of seven. Filtering everything rather than only the withheld trades a contradiction for an empty prompt, which is why a report that withholds nothing is asserted to list everything.

D208 — Closed: _work_order.py was five lines from its own file gate (Low)

src/maintainability_audit/_work_order.py is 745 lines against this repository's max_file_lines of 750, and past warn_file_lines of 600. D207 pushed it to 752 — a hard gate failure on this repository's own audit — and it was brought back under by shortening a docstring whose content is recorded in D207 instead.

That is a legitimate edit and it is not a fix. The next addition to this module fails the gate again, and the pressure to pass it will be pressure to delete a comment, which in this repository is the record of why a rule exists. Two have already been trimmed for length in one day (test_verified_grade, then here).

The module does two jobs, and the seam is visible: building work-order items from findings, and deciding who gets handed what — escalated_fingerprints, withheld_reason, withheld_targets, is_withheld, prompt_items, prompt_advised, prompt_targets. That is the same split _prompt_sections already makes from prompts, whose own docstring states the rule: "composing a document and building the pieces it composes are two jobs".

Filed Open rather than fixed inside D207, because it is a move of roughly two hundred and fifty lines across a module every prompt surface imports, and bundling a refactor of that size into the change that surfaced it would make both harder to review — the rule this tool's own remediation prompt states in every run.

Closed the same day, by the next change needing it. The fix after D207 required roughly ten lines in that module, which would have failed the gate again. The prediction in the paragraph above came true in one step, which is the argument for having written it down rather than trimming a second docstring.

_handoff now holds the whole question of who gets handed what: escalated_fingerprints, withheld_reason, withheld_targets, is_withheld, prompt_items, prompt_advised, prompt_targets and select. _work_order is 491 lines and decides what findings are worth and how they are ordered; the two grow for different reasons.

The boundary was already documented and enforced by nobody. architecture.md said presentation may import "_work_order's presentation-facing prompt selection" — a carve-out naming a module that did not exist. It exists now, so the rule is a module name rather than a sentence, and test_architecture holds it.

Required behaviour: this repository's own thresholds.max_file_lines, enforced by --fail-on-gate in CI, and the layering architecture.md states.

Closing tests: test_the_handoff_rules_are_defined_once, test_the_boundary_runs_one_way and test_neither_module_sits_on_its_own_file_gate in tests/test_the_handoff_rules_are_their_own_module.py.

A pure move changes no behaviour, so the falsifier is the boundary. The first draft of this entry cited nothing and said so, which prove_falsifiers refused — correctly: a closed entry that defends nothing can be undone by the next person who finds the import inconvenient. What is falsifiable is that the seam exists and runs one way. The population is _handoff's own public surface, read from the module, so a rule added there later is covered without editing the test; the file-length assertion reads this repository's published thresholds rather than repeating the number that failed at 752.

Mutation: moving lines out until the number drops below 750 leaves the same module doing two jobs and the same pressure one addition away, so the cut is the seam rather than a line count — _work_order imports nothing from _handoff, which is what makes it a boundary rather than a shuffle. Leaving the moved names re-exported from _work_order would have spared every caller an edit and restored the cycle the split exists to prevent.

D209 — Closed: a clone was filed at one of its copies and told to be removed (Medium)

A duplicate block is a statement about two or more locations. The work order carries one path — _locate takes the first, which is the right place to send someone — and the item read "duplicated block in billing.py" with the target "remove the duplicated block".

An agent handed that deletes billing.py's copy. That clears nothing, because the duplication is a relationship, and it breaks the file the copy was deleted from. The half of the finding that makes extract the right verb is the other location, and no surface carried it.

"Remove" is the word D165 already had to correct, one class over: for risk patterns it read as "delete the matched text", which cleared the finding and left the debt. Same verb, same failure, and the fix there — say what resolving it means instead of naming the finding — is the fix here.

Near-duplicates had the same gap from the other side. Their item said "remove the near-duplicate declaration" and never named what it nearly duplicates, while near_duplicate_section in the same prompt already rendered "near.py:5 copy is 95% identical to orig at orig.py:1". One renderer had it and the work order did not, which is D157's shape: a claim fixed on one surface of one report.

And the first version of this fix reproduced that shape. It put the other locations in the bounded prompt and left the copy-paste block — the one that travels alone into an agent with no report around it — still naming one place. other_sites is now shared by both, because a rule about what a prompt says that lives in two places is how one of them gets fixed alone.

On the shipped demo tree the block now reads Location: billing.py:16 / Also at: invoices.py:15, where it previously named one file and asked for a deletion.

Closing tests: test_a_finding_about_several_places_carries_all_of_them, test_no_multi_place_item_is_told_to_remove_one_copy and test_every_surface_that_prints_a_location_prints_the_others in tests/test_a_clone_names_every_copy.py. The population is COUNTED_SOURCES — every finding class the report carries as a located list, read from the module that defines them — and which of those are about more than one place is answered by _sites, the same function the builder uses, rather than by a list in the test.

Mutation: asserting the item dict carries the locations passes while every rendered surface still prints one, which is exactly what the first attempt did; so the two surfaces that print Location: are rendered and searched. Fixing the location and leaving the verb passes a check about locations while still instructing a deletion, so the target is asserted separately. Attaching a site list to dead code or a risk pattern — one declaration, one match — puts a second location in a prompt that has one, so the single-place classes are asserted to gain nothing.

D210 — Closed: an unmeasured pillar reported this repository's practice as its own (Medium)

Practice level is a fact about the repository — is a linter wired into CI, is there a coverage gate, are ADRs maintained. The pillar section states it once, above the table: "Practice level 4 of 5". Every row then repeated that one number in its own Practice column, including the rows for pillars this tool does not measure.

So the efficiency row read practice 4 for a pillar ADR 007 declares out of scope, and an unmeasured security row said the same. A fact about the repository became a claim about a pillar nobody took a reading on, sitting beside a dash in the Condition column that said so.

posture was nulled for exactly this reason and practice was left behind. _measured_entry's own comment records the first version printing "efficiency — healthy" from the practice axis alone: "a maturity level vouching for code the tool had explicitly declared out of scope, which is the exact silence-reads-as-fine defect this pillar block exists to end." The fix nulled the derived cell and left its input, so the number kept printing.

Both axes are now absent for a pillar with no reading of its own, and one shared practice_cell prints the dash, beside pillar_cells which already does the same for condition.

A delegated pillar that came back with a document keeps its number, and it is a different one: secure-code-agent measures security practice and reports its own level, which is a real measurement of that pillar rather than this repository's standing in for one.

Closing tests: test_an_unmeasured_pillar_carries_no_practice_level, test_no_skin_prints_a_digit_for_an_unmeasured_pillar and test_a_delegated_pillar_with_a_document_reports_the_producers_level in tests/test_an_unmeasured_pillar_reports_no_number.py. The population is PILLARS, read from the module that declares them.

Mutation: the audit reported this on the security row; efficiency had the identical row for the identical reason, so a check written from the reported instance ships a fix that is still wrong one row up — the population is every pillar outside this tool's own measurement. Nulling the field and leaving the renderer on str(None) passes an assertion about the field and puts the word "None" in the table, so the rendered cell is asserted separately. Blanking the column everywhere passes both and removes the axis ADR 007 exists to keep beside condition, so an in-scope pillar is asserted to still report it, and the delegated fixture's level differs from this repository's so the two cannot be confused.

D211 — Closed: the audited tree could redirect a read out of itself (Medium)

config.repository_path has always bounded a configured path: it resolves the target, refuses one outside the root, then walks the lexical route and refuses a symlinked component — because an inward .maintainability -> src link passes a containment check on the resolved path and shows only on the route (D34). Its refusal states the principle outright: "the audited tree cannot redirect where this agent reads or writes."

Three readers take their paths from the audited tree and never went through it: practice detection, test-command detection and the generated-file banner. D147 routed them through the regular-file door, which checks what a path is. Nothing checked where it goes.

The consequence was measurable, not theoretical. A repository whose pyproject.toml is a symlink to a file outside its root scores practice level 2 with a linter-config signal; the same tree with an ordinary in-root file scores 1. The audited tree raised its own reported level using a file this tool was never pointed at — and practice level feeds the pillar posture, so it reaches the report a reader acts on.

That is P3's shape reflected. P3 forbids withholding evidence from improving a reported value; this was planting evidence outside the boundary to improve one.

refuse_symlinked_route moved from config down into _operator_reads, which is where the reading primitive lives and which config already reads through — so the rule sits below its callers rather than above them. open_repository_file composes it with the containment check and the regular-file door, and both checks are kept because either alone leaks: the route catches an inward link that resolves back inside, the containment check catches an absolute target the walk never meets.

The operator door stays exempt, and says so. An operator names their own config and a symlinked config is an ordinary setup; the difference this entry turns on is who chose the path.

_banner is the one reader left on the unbounded door, stated rather than overlooked: it is called only from the discovery walk, on a path that walk produced, and discovery does not descend an outbound symlink — a tree carrying leaked.py -> ../outside.py audits with files_scanned: 1, verified before this entry was written. Its input is bounded before it is handed over, and the test names it as the sole exemption so a fourth reader cannot join it quietly.

Closing tests: test_an_outbound_symlink_is_refused, test_an_inward_symlink_is_refused_too and test_the_tree_cannot_raise_its_own_practice_level in tests/test_the_tree_cannot_redirect_a_read.py. The population is derived from the doors the modules actually call, so a fourth reader appears in a failure message rather than in nobody's list.

Mutation: checking containment alone passes the outbound case and lets an inward shadow -> real link through, which is the half D34 exists for; walking the route alone passes that and lets an absolute target the walk never meets through. Asserting a fixed practice level rather than equality with the unlinked tree goes stale the first time the rubric moves, so the worked example compares the two trees. And a boundary that refused everything would pass every refusal assertion while measuring nothing, so an ordinary in-tree file is asserted to still read.

Related: tests/test_operator_named_reads.py's single-resolution check was scoped from the whole module to the functions that perform the read. Scanning the file was a proxy that held while the file held only the read path; a route walk resolves ancestor names because that is what a route walk is. It runs before any handle exists, inspects ancestors rather than the target, and the file is still opened once and validated through that one handle — so the property is unchanged and the proxy was replaced with the claim.

D212 — Closed: C#'s primary loop was not a branch (Medium)

foreach is how C# iterates. The C-family complexity pattern matched \bfor\b, and that word boundary falls after for — foreach has an e there, so it never matched. Two methods that do the same work:

WithForeach   complexity=1  cognitive=0
WithFor       complexity=2  cognitive=2

The first contains a loop. Both metrics read it as straight-line code, and every rate computed from them is a rate over a denominator that missed it.

This is the fourth instance of one class, and the first three were each fixed alone. Fortran's do: "six nested loops once scored complexity 1 because the pattern did not know the keyword." Swift's guard and repeat: "a function built from guards reads as having no branches at all, which is the Fortran do defect in another language." PHP's elseif and foreach, added after checking every construct against a reference implementation: "a dispatch chain scored zero."

Each note names the previous one. None of them produced a check over every language, so the fifth instance was already sitting in the shared C-family pattern that PHP's foreach note is printed beside — the same keyword, missing in two places, fixed in one.

The independent-implementation check disagreed, and the grammar settles it. test_every_construct_agrees_with_an_independent_implementation reported ForEach: ours=2 lizard=1, and its own message says neither number is authoritative. The C# specification defines foreach (V v in x) by its expansion: a while (e.MoveNext()) loop over an enumerator. The loop condition is in the language's own definition of the statement, and while is counted here and by lizard alike. Microsoft's metric and SonarQube both count it. Declared in DECLARED_DIVERGENCES with that reasoning, which is what that mechanism is for — and a reminder the file already records in another entry: lizard is a second opinion, not an oracle.

Closing tests: test_an_idiomatic_loop_costs_what_a_counted_loop_costs and test_a_loop_is_never_free in tests/test_every_language_counts_its_own_loop.py, parametrised over ten languages.

tests/test_every_language_has_a_loop_fixture.py holds the population rule — every parsed language owes a pair — and is not cited, because the fixtures it counts live in the test file and so it passes at this entry's base. It is the mechanism whose absence let one defect survive three fixes: nothing ever asked whether the other nine languages had the same hole.

Mutation: asserting the two spellings agree is satisfied by a pattern that knows neither, which is the Fortran do defect passing its own test with both sides at 1 — so a loop is separately asserted never to cost what a branchless routine costs. Writing the check for C# alone repeats exactly what the three previous fixes did, so it is parametrised over every parsed language and a language with no fixture fails rather than being skipped. Taking lizard as the oracle would have reverted the fix instead of declaring the divergence.

D213 — Closed: a question published one choice and accepted any number (Medium)

The three labor rates published options: [90], [140], [210] — a one-item enumeration — and meant it as a type marker. The elicitation model builder reads a non-string option list as "float field, any value", so that door worked: any rate went through.

A host on the data path reads options as what the word says and offers the single value. Submitting a real senior rate meant contradicting the published contract, which is what the operator hit entering 130/165/275 against a published 90/140/210 — he got the rates in, by ignoring what the tool said it would accept.

One field meant two things depending on which door read it. That is the D189/D190/D193 family, where the elicitation path and the data path disagreed about the same setup; this instance had them disagreeing about the type of an answer rather than about which questions to ask. It is also D191's shape reversed: there a completed setup produced a file the tool's own contract rejected, here the contract offered less than the code accepted.

type: "number" says what the field is, and default goes back to being a suggestion rather than the only permitted answer. Both readers ask the question what it is instead of inferring it from the shape of its options.

Closing test: test_no_question_offers_a_single_choice in tests/test_a_published_question_offers_what_it_accepts.py. The population is every published question across both stages, so a fourth question given a one-item enumeration as a type hint is caught the same way rather than only the three rates.

Only the published shape falsifies, and the reason is the finding. tests/test_a_setup_question_keeps_its_contract.py holds the rest and is not cited: submitting 130 against a published options: [90] worked before this change, because the validator already accepted any number while the question offered one. That asymmetry was the defect, so a test of the accepting half cannot fail at this entry's base. It is kept because the rates must still arrive as floats — a rate that never converted is what left the economic context pending forever in D193 — and because the refusals guard the branch this change touched on its way past.

Mutation: dropping the enumeration could have dropped the validation with it, which accepts "ninety" and carries a string into money — so a non-numeric rate is asserted to still be refused, and the coerced value is asserted to be a float because a rate that never converted is exactly what left the economic context pending forever in D193. Touching the numeric branch also touches the one that refuses depth: "nuclear", so a real menu is asserted to still refuse what it does not offer. And checking the three rates rather than sweeping every question would leave the next one-item enumeration to be found by another operator.

Found auditing a private application on 2026-09-26 with 3.9.0. The audit executed pytest -n auto --cov=maintainability_audit --cov-report=xml:coverage.xml -q — this repository's own suite — against a JavaScript and Python tree it has nothing to do with. pytest exited 5, coverage collected nothing, and the report said so in one line with nothing flagging the command as another repository's.

D147 and D152 put the choice of program in the person's tier, correctly: the tree may say how it is tested, only the person decides what this host runs. But the tier held one expected_commands.test, so consent given while setting up one repository was consent for every repository audited afterwards. Every person who opted in and audited a second repository ran the first one's program there.

Consent is recorded per repository now, keyed by the resolved root path, under test_execution.commands in the person's tier. A repository with no consent of its own runs nothing and the report says the consent that exists was given for another repository — counted, never named, because a report is about one repository and may be shared — with the remedy. A consent recorded before this — unkeyed — runs nowhere, because which repository it was given for is not knowable, and the report says that too. A blank answer records a decline for that repository alone; it no longer switches off the person's opt-in everywhere, and the gate does not ask again for it.

That is Decision 9's own rule — the suite runs "only because you opted in, and only for this repository" — which a single unkeyed command could not keep. The first build of this fix was audited before release and failed twice: a full setup of any repository erased every recorded consent, because the setup payload replaced the person's test_execution; and the reconfigure the report named could not ask the command, because whether it is pending was read from the repository's config, which already held one. Both are fixed, with one rule for both doors: test_command_pending reads the person's tier, so a repository with no consent of its own is asked its command — on reconfigure, on a new repository whose person's answers carry (Decision 4, 2026-09-27), and at the terminal, which records the consent through the same writer as chat and no longer re-asks the full setup when the person's answers exist (first-run.md).

A path rather than a remote, because a remote is shared by every clone and fork while consent is given for one tree on one host. A moved checkout is asked again, which is the safe direction.

Closing test: test_another_repository_is_told_the_consent_was_not_its_own in tests/test_consent_is_per_repository.py, which asserts nothing is spawned; the file holds the unkeyed, decline, spelling and no-other-path cases beside it. tests/test_consent_survives_setup_and_is_asked.py holds the setup, migration, new-repository and terminal cases.

Mutation: keying by the path as spelled rather than resolved — outside the closing test, which uses one spelling — is caught by test_the_same_repository_by_another_spelling_is_the_same_repository. A decline that also switched the person's opt-in off everywhere passed the first draft of the decline test, which checked only the other repository's command; it now asserts the opt-in survives, and the mutation fails it. Five more, each run and each caught: setup writing the payload without the recorded consents, pending read from the repository tier, a new repository asked nothing, the terminal re-running the full setup, and the refusal naming the other repository.

D215 — Closed: behaviour suites under acceptance/ were graded as production (Medium)

Found in the same audit. The application's behaviour tests are more than sixty suites in acceptance/<suite>/run.mjs, each importing and driving the production handlers. All were graded as production code: 18 of 25 duplicate blocks, all 25 near-duplicates and most oversized files and declarations were in them, and 106 production files were reported unpaired while those suites covered functions/ at 98%. Worse debt and worse testing, from one misclassification.

The directory list gains acceptance and e2e, decided by the operator over a per-repository paths.test_roots, which would have had to reach fifteen call sites. A wider list was proposed and withdrawn the same day: cypress and playwright are production code in their own projects, and microsoft/playwright is in the calibration corpus, so adding them would have re-measured a corpus repository in a patch release. integration and features are ordinary production package names. Reading production as test flatters the grade.

There were two lists. _discovery read testing/ as tests while is_test_path did not, so provenance and pairing disagree about lapack's TESTING/. The list now lives beside is_test_path and _discovery reads it, and testing stays the one name discovery adds for itself — declared as DISCOVERY_ONLY_TEST_DIRECTORIES — until the next recalibration, because lapack is in the corpus.

Closing test: test_a_handler_driven_by_an_acceptance_suite_is_paired in tests/test_behaviour_suites_are_tests.py, the fixture the defect asked for; test_both_classifiers_read_one_list holds the two lists to one list and one declared exception.

Mutation: dropping acceptance from the list fails the closing test and the parametrized directory test. Giving _discovery a second name of its own — fixtures — passed at first, because agreement was checked only over names already in the list, which is the one population a second list does not touch. test_the_classifiers_agree_outside_the_list_too now samples names outside it and catches the mutation.

D216 — Closed: the chat door sent the whole report instead of saving it (High)

Found on a private application's first chat-door run with 3.9.0. format: "html" returned the complete HTML report inline — 257,306 characters in report_html, beside the Markdown, the security work order and the prompt, 308,957 in all. Claude Code refused a result that size, nothing was saved, and the report and its history charts reached nobody.

The spec was stated long before: the report is the complete record and the chat view is bounded. The size fix of 2026-08-31 (e21e2e7) bounded one field, report_markdown, and in the same change left html carrying the complete document in report_html. Its tests measured that one field, and one of them listed report_html as an expected key — so for four weeks the suite defended the defect. ADR 011 had the host save returned text, which stopped working the day the HTML became the complete report.

A chosen markdown or html report is now saved by the MCP server to output_path, the location the person chose when the host asked where to save; the reply carries the bounded view and report_path. A file format with no location is refused before the audit runs. The location must be in the repository or an allowed root, through the bounded writer. The saved HTML carries a generation banner, and discovery now reads generation banners on markup, so the next audit measures the repository rather than the last report. ADR 011 is amended; the skill, the server instructions and first-run.md say the same.

Closing test: test_the_whole_reply_stays_under_the_budget in tests/test_the_chat_payload_has_a_budget.py — the whole serialized reply, for every presentation read from PRESENTATIONS, on a repository whose complete report is asserted to be more than twice the budget. Beside it, test_the_complete_report_never_travels_in_the_reply, and tests/test_the_report_is_saved_not_sent.py for the file, the refusal, the boundary and the next scan.

Mutation: restoring 3.9.0's inline report_html fails the budget and the never-travels test for html; restoring the complete Markdown for the markdown format fails the budget for markdown. Both were run against these tests before they were committed. The mutation that let the original defect through — a test that measures one field — is the one the budget test cannot express: it measures the reply.

The audit before release, 2026-09-27. The first build of this fix could overwrite any regular file inside an allowed root — the config, the history, a source file — and resolved a symlinked directory before the writer's route check could see it, so the audited tree could choose where the report landed. The server's own instructions still told hosts to "save the returned text". A save now replaces only a file whose first line is the report banner, the route is normalised and never resolved before the bounded writer walks it, saved Markdown carries the banner too, and the instructions say to pass output_path. The same audit found a repository file could force the test-command question after the person declined (D214), and that a quoted (( fooled the Shell shift guard (D217). Each has a test that failed first and a mutation that was run and caught.

D217 — Closed: Shell lost functions after an arithmetic shift, and counted arguments as branches (Medium)

Found by the drift audit of 3.9.0, two days after Shell shipped. << inside $(( … )) or (( … )) is a left shift, and the masker read it as a heredoc waiting for a line n or 2 — so f(){ x=$((1 << n)); } hid every function after it. Not an under-report: a population silently lost, which docs/languages/shell.md said could not happen. And the command position rule, a lookbehind on then/do/else, fired on the word wherever it appeared, so echo do for it counted a loop.

The masker now leaves << alone inside an open ((. Command position is read by a small tokenizer: a separator starts a command, and then, do and else continue it only when they were themselves in command position. The language page now names every over-count the case-arm pattern makes, rather than claiming one, and the grammar specimens gained the sequential and asynchronous lists and bash's ;& and ;;& terminators, with the page saying plainly that the list is typed from the standard's headings and checked against the specimens, not against the standard.

Closing test: test_an_arithmetic_shift_is_not_a_heredoc in tests/test_shell_declarations.py, and test_a_keyword_after_do_in_an_argument_is_not_a_branch in tests/test_shell_metrics.py.

Mutation: removing the arithmetic guard fails the first; letting do continue command position as an argument fails the second. Both were run. The case-arm over-counts are declared rather than fixed: the branch reader reads one line at a time and cannot see whether a case is open.

D218 — Closed: 3.11.0's diff reading missed quoted paths, and its oracle check had gaps (High)

The audit of v3.11.0 on 2026-09-28, every finding verified against the code before it was fixed:

Closing test: test_a_quoted_path_is_read in tests/test_audit_of_3_11.py, which holds one test per finding.

Mutation: reading only the bare header form fails the quoted-path test; counting any call as a helper fails the print test; checking only the final path component fails the symlinked-directory test. Each was run.

Found extending the calibration corpus to Kotlin on 2026-10-01. Magisk ships native/src/base/include/rust/cxx.h as a symlink into a git submodule a plain clone does not fetch. The target does not exist, so is_file() is false, and the walk raised D141's refusal — written for a device, socket or FIFO — and the whole audit ended in a traceback. Any repository with a link into an unfetched submodule could not be audited at all.

D141 was silent on this case; its refusal names devices and FIFOs, and a dangling link is neither. Decided by the operator on 2026-10-01: skip it, and name it. There is nothing at the end of the link to measure, so skipping under-reports nothing, and D141's other lesson stands — a scan that measures less than it was asked to says so. Both walks skip a source-suffixed symlink whose target is missing; the report carries them as summary.dangling_source_links, and the markdown and HTML skins list them under Links not followed. A FIFO is still refused.

The calibration verifier had the same shape one level up: one refused candidate ended the run and discarded every candidate after it. A refusal is now a rejection with its reason.

Closing test: test_the_audit_completes_and_names_the_link in tests/test_a_dangling_source_link_is_named_not_fatal.py, beside the markdown and HTML cases and one that adds a FIFO to the same tree; and test_a_candidate_the_scanner_refuses_is_rejected_and_named_not_fatal in tests/test_calibration_tooling.py.

Mutation: restoring the walk's refusal for a dangling link (dropping dangling(path) from iter_files) fails four of the five, the audit ending in the traceback again; removing the HTML section fails only the HTML test, which is why the three skins are tested separately. Each was run.

D220 — Closed: analyzers handed a file list read only part of a large tree (High)

Found reading the 4.0.0 corpus run's log on 2026-10-02. Six adapters hand their tool explicit files — complexipy, multimetric, cohesion, pydocstyle, PMD and Checkstyle — and expand_files cut the list at a quarter of ARG_MAX, logging the cut (#5). Stated, but still a partial reading: FFmpeg's analyzers saw 1,543 of its 4,687 files, webpack's 1,426 of 11,409, and the largest repositories in the corpus and in any audit were measured on a fraction of themselves while the report described the tree.

Nothing is cut now. _runner.batches splits the list so each part fits one command line, BaseAdapter.invocations gives one run per batch, and _analysis parses each as it finishes and merges them; a batch that fails stops the rest and is the tool's outcome, so a partial reading cannot pass for a whole one. On FFmpeg multimetric now measures all 4,687 files, in four runs.

Closing test: test_every_file_list_adapter_names_every_file_across_its_runs in tests/test_analyzer_file_lists_are_batched.py, derived from the adapter registry, beside the list, split and merge cases.

Mutation: giving every adapter one run again (target_files ignored in invocations) fails two of the four. Run.

D221 — Closed: the scanner fingerprint missed most of the measurement path (Medium)

Found building resumable calibration runs on 2026-10-02. measure.py --reuse keeps a stored corpus row while its scanner_fingerprint matches, and the fingerprint digests MEASUREMENT_PATH, a list typed by hand. It named seven of the fifteen language scanners and none of the twenty-three other modules those import — the analyzer catalog, the runner, the XML reader among them. A change to the Kotlin scanner left the fingerprint unchanged, so a reused row measured by the old scanner passed for a current one. The 4.0.0 constants are unaffected: that run re-measured every row.

The list is now held to the import closure of itself, so a module a measurement passes through cannot be left out by forgetting it. One module is excluded with its reason: _calibration holds the constants fitted from the rows and only normalizes them for display.

Closing test: test_every_module_a_measurement_reaches_is_fingerprinted in tests/test_scanner_fingerprint_covers_the_measurement_path.py.

Mutation: dropping _ranges_kotlin from the list fails two of the three. Run.

D222 — Closed: asking about a repository ran a command that repository chose (High)

Found by the audit of 4.0.0 on 2026-10-02, reproduced before any fix. 3.12.0 named missing security scanners before the run by calling secure-code-agent's --preflight from the chat door's run-or-reconfigure reply — before anyone had said "run". The preflight reads the audited tree's secure-code-agent.json and probes each scanner's configured command with --version, and that file could name any program on PATH with any arguments. A tree setting bandit's command to python -c "<code>" ran that code on the auditor's machine the moment the door was asked about it: a marker file written by the tree's command appeared with no run requested. "Nothing is audited until the user has been asked twice" did not hold for the first thing that ran. The run after consent had the same exposure through the delegate itself, since D177: secure-code-agent refused a command from inside the tree and not one from PATH with the tree's arguments.

Decided by the operator on 2026-10-02: fix both now. Before consent the preflight is handed a neutral configuration and never reads the tree's; it still names the default scanners that are missing. secure-code-agent 0.12.10 (its D29) no longer lets an untrusted config choose any scanner's command, and this project's supported floor is raised to it, in the code, every CI pin, and every documented install line — the last now tested, because three documents still said >=0.12.2.

Two Mediums from the same audit closed with it. The bounded chat view lists skipped dangling links, which only the complete report did (D219). And a symlink to a real file outside the tree was counted as read source though the scan never opens it; the read share now drops it as the scan does. The third — a required scanner the tree's config switches off is never named — is secure-code-agent's to answer in its own preflight (Decision 14), and is left open there rather than re-authored here.

Closing test: test_before_consent_the_preflight_is_handed_a_neutral_config in tests/test_missing_security_scanners_are_named_before_the_run.py. The end-to-end guard is tests/test_preflight_before_consent_runs_nothing_from_the_tree.py, which runs the real delegate against a hostile tree before consent, through the chat door, and after consent.

Mutation: the preflight without the neutral --config fails the closing test; with secure-code-agent 0.12.9 installed it also fails all three end-to-end cases, the marker file written by the tree's command. Both were run before the fix. With 0.12.10 the delegate refuses the command on its own, which is why the end-to-end file states that it passes at the base.

D223 — Closed: cohesion was recorded as failed on every repository without Python (Low)

Found on FFmpeg while previewing D220, 2026-10-02. cohesion is handed explicit .py files; with none it was spawned with an empty --files and exited 2 with a usage error. Every repository without Python — FFmpeg and most of the calibration corpus — carried a failed cohesion row in its analyzer coverage. No score moved, because a failed tool contributes nothing, but the coverage said a tool had broken when it had nothing to read. PMD and Checkstyle already answered this with has_targets, which _attempt checks before spawning anything; cohesion now does the same and is reported not applicable.

Closing test: test_no_python_means_not_applicable_and_nothing_spawned in tests/test_cohesion_with_nothing_to_read_is_not_applicable.py.

Mutation: has_targets returning True fails it, cohesion spawned and failed again. Run.

D224 — Closed: a committed docs site was scored as source, and adding an exclusion discarded the defaults (Medium)

Reported from secure-code-agent as issue #292, measured there: adding its rendered docs/html/ took files from 160 to 195, duplicate blocks from 22 to 44 and risk findings from 5 to 8, with no source change. Every page is a rendering of a Markdown file that is also scanned, so each document counted twice; a debt marker in README.md was reported again in docs/html/readme.html, where editing it is forbidden and re-rendering reproduces it. The rule that every repository commits such a site made it universal, and this repository's own audit had it too.

Not a docs/html/ exclusion, which is the directory-name rule ADR 010 rejected. tools/render_docs.py now opens every page with the @generated banner discovery already honours, so the site is generated wherever a renderer says so. The reporter could not exclude it by hand either: deep_update replaces lists, so setting paths.exclude_patterns discarded every default exclusion and froze a copy. paths.exclude_additional appends to the tiers before it.

Closing tests: test_a_stamped_page_is_not_scored_beside_its_markdown in tests/test_a_rendered_docs_site_is_not_scored_as_source.py, and test_additions_keep_every_default in tests/test_exclude_additional_keeps_the_defaults.py.

Mutation: both failed before the change — the page's debt marker reported beside the Markdown's, and the added pattern missing from the merged list.

D225 — Closed: every read failed on Windows for want of a flag (Low)

The Windows probe failed on every run: open_regular_file opened with os.O_NONBLOCK, which Windows does not have, so every configuration and source read raised AttributeError. The flag exists so a POSIX FIFO cannot hang the open before the regular-file check; Windows has no such FIFO in the tree, so where the flag does not exist the open goes without it and the check that follows is unchanged. This project still declares itself POSIX-only; this removes a crash, not that statement.

Closing test: test_a_regular_file_opens_where_the_flag_does_not_exist in tests/test_regular_file_reads_work_without_o_nonblock.py.

Mutation: with the flag removed from os the read raised the probe's own error before the change. Run.

D226 — Closed: three analyzers were lost to every audit, for reasons nobody could see (High)

Reported by secure-code-agent's session on 2026-10-04: a repository that audits clean graded C with structure, style and documentation unexamined. Reproduced on this repository's own audit; each cause was found by running the tool the way the audit runs it.

The class behind all three: an outcome read from an exit code rather than from what the tool wrote. A tool that wrote no output failed, whatever it exited with, and the failure carries what it said — the rule secure-code- agent recorded as its D30, now applied to every adapter. With it, each of the three would have named its cause the first time it happened. On this repository cohesion now reports 106 measurements and mypy runs with valid exclusions; pylint now analyses the whole tree and exceeds its time limit, which the report says, instead of claiming the output was unreadable.

Closing test: test_every_regex_dialect_receives_valid_regular_expressions in tests/test_analyzers_report_what_actually_failed.py, derived from the adapter registry, beside the stderr rule, the per-path interrogate exclusion and cohesion's probe — the last two also against the real tools where installed.

Mutation: each failed before its change: the default excludes did not compile ("nothing to repeat"), the silent run was recorded as having run, interrogate's exclusions arrived as one argument, and cohesion's probe exited 2.

D227 — Closed: private repositories were named in this public one (High)

Found on 2026-10-09. This repository is public; its owner's other repositories are private, and three of their names were in the tree — in the changelog, this register, a document's file name, code comments, test docstrings and the scan history's branch field. Two of them had been there for weeks.

The guard that existed (D183) could not have caught it. It checked only the lines a change added, so a name already in the tree was invisible to it, and it checked them against a term list that had never been given these names: nothing derived the list from the place the names are defined, the owner's private repositories.

The names are replaced with neutral wording ("a private application", "a private repository") and the document named after one is renamed. The scan history's branch field is rewritten; nothing reads it as a key. Git history and the published packages are left as they are, by the owner's decision.

The check's first run in CI found more: three versions of a business document, each as HTML and as .docx, naming an organisation the existing term list already forbade. They had been committed after that term was forbidden, past a check that read only added lines, and the .docx copies were binary, so a text scan would have skipped them too. They are removed from the tree and kept privately; they were never in a published package.

The class: a public repository naming a private one. tools/check_private_names.py reads every tracked file, every tracked path and every commit message in a change, case-insensitively, against names that never live in the tree — the FORBIDDEN_TERMS and PRIVATE_NAMES secrets, and locally ~/.config/private-names, generated from the owner's non-public repositories with gh repo list. A match is reported by file and line, never by the text. With no names it exits 2: a guard with nothing to look for has proved nothing. It runs in the required "Commit identity and signatures" job.

Closing test: test_this_repository_names_no_private_repository in tests/test_no_private_names.py, beside a name in a file, a path or a commit message being found in any case, the report never repeating it, and no names failing; and test_a_name_already_in_the_tree_fails_though_the_change_never_touched_it in tests/test_authorship_gates.py, which runs the CI step itself.

Binary files are read as well: each member of a zip (a .docx, .xlsx or .pptx is one), otherwise the file's bytes.

Mutation: every test failed before the tool existed, and the office-file test failed while binary files were skipped; with the scan case-sensitive, the three that use a mixed-case name failed.

D228 — Closed: an analyzer's findings the grade does not use looked ignored (Medium)

Reported from the field on 2026-10-12, auditing a C# repository with 4.3: the analyzers' output for C# was not used. Reproduced on that repository. jscpd ran and reported 439 duplicate blocks in its .cs files; the report listed them, and the duplication grade came from the built-in detector's 196. That is the design — only declarations takes its score from the analyzer tier (_criteria_scope.ANALYZER_DIMENSIONS), recorded in docs/decisions.md — and nothing on the page said so, which P8 forbids.

When an analyzer that ran reports findings for a concept whose dimension is scored from the built-in detectors only, the coverage section now names the tool, the count and the dimension, and says the count does not move the grade. It is derived from the coverage document and the dimension table, so a later analyzer in the same position is named too. The markdown, chat and HTML views share the one renderer.

The rest of the report holds: C# is read by lizard and jscpd only, and style, documentation, types, dead-code and testing are listed as unexamined for it. A C# analyzer adapter is a separate feature, queued.

Closing test: test_the_coverage_section_says_the_count_does_not_move_the_grade in tests/test_reported_but_not_scored.py, beside the listing rule and the analyzers it must not name.

Mutation: every test failed before reported_not_scored existed.

D229 — Closed: an analyzer rewrote the repository it was auditing (High)

Found on 2026-10-14 auditing a private repository through the chat door with 4.4.0: the audit changed 102 of that repository's tracked files — unused imports deleted, imports re-sorted — and its acceptance suites broke. The ruff adapter ran ruff check without --no-fix, and ruff honours a repository's own fix = true; the fortitude adapter had the same shape. The intent page has always said the audit never edits source, and nothing checked it.

ruff and fortitude now run with --no-fix. The class is closed structurally: the audited tree's tracked state is snapshotted, through run_git, around every analyzer run, and any change raises RepositoryModifiedError naming the files. It is a declared MCP refusal and the adapter backstop re-raises it, so no grade is returned for code that is no longer what was audited. A guard in tests/test_adapters.py forbids write-enabling flags in every adapter's argv, and another holds that a run that changes nothing is not an integrity failure.

Closing tests: test_the_linters_that_can_fix_are_told_not_to, test_an_analyzer_that_edits_a_tracked_file_is_an_integrity_failure and test_real_ruff_with_fix_true_leaves_the_audited_repository_untouched in tests/test_analyzers_never_write.py.

Mutation: each failed before the fix; the real-ruff test reproduced the incident's rewrite on a scratch repository with fix = true.

D230 — Closed: the local updater downgraded the install and never re-synced the skill (High)

Reported by the operator on 2026-10-15. The updater was an untracked script beside the venv, run hourly by a launch agent. It read PyPI's info.version, which lags a release, and on 2026-09-22 it installed 3.7.19 over 3.7.29 — a downgrade; it never ran --install-skill, which this README says to run after every upgrade; it only wrote a marker that the MCP server needed a restart; and nothing in the product tested any of it.

The updater is now a product command. --update lets the installer resolve the version (uv pip install --upgrade, or pip without uv), so the package holds no HTTP client and reads no release feed; reads the installed version before and after through _runner.run; refuses a lower result as a downgrade, naming both; on a higher one re-syncs the skill and records that the MCP server needs a restart. --install-updater writes the hourly launch agent as io.github.marshallguillory86.maintainability-agent.update, replacing the personal-prefix legacy label so two updaters never run.

Closing tests: every test in tests/test_self_update.py, among them test_downgrade_is_an_error_and_does_not_sync, the uv and pip install command lines, the skill re-sync and restart marker, and the launch agent.

Mutation: every test failed before _self_update existed.

D231 — Closed: the test suite wrote into whichever repository its launcher named (High)

Found on 2026-10-15, the first time the new pre-push gate ran the suite. A git hook exports GIT_DIR and its siblings, and the suite isolated git's configuration (HOME, GIT_CONFIG_*) but never its location. Every fixture git init, commit and config therefore wrote into the real repository: sixty fixture commits landed on a real branch and were pushed, and its .git/config gained core.bare = true, a fixture hooks path, a fixture identity and a temporary signing key. Nothing was merged; the branch was rebuilt from its last clean commit and the config repaired.

tests/conftest.py now drops every name git rev-parse --local-env-vars prints, at import, before collection or any test runs git. The names come from git, so a variable a later git adds is dropped too. The gate script also clears them itself.

Closing test: test_git_location_variables_from_the_launcher_never_reach_fixture_repositories, in tests/test_suite_isolation.py: it runs a fixture commit in a child pytest with GIT_DIR and GIT_WORK_TREE naming a sentinel repository and asserts the sentinel's head, commit count and config are untouched.

Mutation: before the fix the sentinel gained a commit and its config changed.

D232 — Closed: --update told the operator to run /mcp, which restarts nothing (Medium)

Reported by the operator on 2026-10-15, the day 4.4.2 shipped. After an upgrade, --update said "Restart the MCP server to use it (/mcp in Claude Code)". In the VS Code panel /mcp lists the servers and reloads nothing, so every running server went on serving the old code, and the instruction cost him the attempt.

A stdio server holds the code it loaded at spawn, and a host starts a stopped one again on its next call. So each MCP server now records its pid and version (_mcp_servers.register) and marks itself busy for the length of an audit. --update, on every run, stops the servers on an older version that are idle, skips the busy ones for the next hourly run, drops records whose pid is dead or now belongs to another program, and says what it did. The message no longer names /mcp.

Closing tests: every test in tests/test_mcp_servers.py, among them test_busy_server_is_left_alone_and_reported_busy and test_reused_pid_is_never_killed, and the message tests in tests/test_self_update.py, among them test_upgrade_stops_stale_servers_and_says_so.

Mutation: every new test failed before _mcp_servers existed.

D233 — Closed: a production file named test… never paired with its test (High)

Reported on 2026-10-24 (issue #306) from a real repository's audit, and reproduced on 4.4.3. subject_stem applied pFUnit's camelCase rule — testFooBar_mod.F90 covers fooBar_mod.F90 — to every file in every language, so a production file whose name starts with test lost those four letters: scripts/test-rp.mjs became -rp, src/tester.py became er, src/testing.mjs became ing. Its stem could never meet the test covering it, so it was reported as an unpaired fail-band production unit, and the import route missed it too, since it compares against the same stem. In the repository where it was found it was the only unpaired unit left, so it alone held testability at its 4.0 cap, and the work order asked for a test that already existed.

The rule is pFUnit's, so it now applies only to a Fortran file (.f, .for, .f90, .f95, .f03, .f08, .pf) that is itself a test. Every other name keeps its whole stem; the pFUnit pairing is unchanged.

Closing test: test_a_production_file_named_test_something_pairs_with_its_test in tests/test_a_production_name_keeps_its_test_prefix.py, beside the stem of a production file in four languages. The pFUnit convention it must keep is still held by the Fortran pairing test in tests/test_tdd_structure.py.

Mutation: all eight failed before the change.

D234 — Closed: the audit of main failed on every push, and crashed when it did (High)

Reported by the operator on 2026-10-26 ("Maintainability: All jobs have failed"). Decision 18 (2026-10-15) moved maintainability.yml from pull requests to pushes on main to stop running each check twice, and its changed-only stayed origin/${{ github.base_ref }}...HEAD. base_ref is set only on a pull request; on a push the range is origin/...HEAD, which git cannot resolve. The job failed on every push to main from that day — three in a row before anyone looked — while every pull request passed.

It failed badly as well: changed_paths raised GitCommandFailed and the CLI printed a traceback, so the log could not say whether the input or the tool was wrong. The pre-commit mode already refused the same failure by name with exit 2.

The workflow now audits the push's own range, before...HEAD, and a manual run the last commit. A range git cannot resolve is refused by name with exit 2. The class: a job reading base_ref on an event where it is empty. Every workflow job that reads it must run only on pull requests or be guarded to them, read from the workflow files.

Closing test: test_every_job_reading_base_ref_runs_only_on_pull_requests and test_a_range_git_cannot_resolve_is_refused_by_name_not_a_traceback in tests/test_base_ref_only_where_it_exists.py.

Mutation: the first named maintainability.yml:maintainability before the change; the second raised GitCommandFailed: git diff exited 128.

D235 — Closed: four analyzers left a temp directory behind on every run, and so did the suite (High)

Reported on 2026-10-26 when the operator's disk reached 98%: the system temp directory held 74,535 directories from this tool — 21,294 jscpd-, 20,185 complexipy-, 16,952 checkstyle-, 16,104 spotbugs- — and removing them freed 13.5 GB.

Those four adapters made a scratch directory with tempfile.mkdtemp in their constructors and never removed it. A directory was left for every adapter object built, whether or not its tool ran: the registry is built on every audit and many times in every test run, which is how the count reached tens of thousands. Five of this repository's own tests did the same with a bare mkdtemp.

BaseAdapter.scratch() now makes the directory on first use, when a tool is about to run, and _run_one removes it in a finally once the output is read, on every path including the backstop. The tests use tmp_path, or a TemporaryDirectory where they need a path under /var.

The repo-a-, repo-b- and python-test-discovery- directories found beside them are not this tool's: no code or test here makes them.

Closing test: test_a_run_removes_its_scratch_directory_whatever_the_outcome and test_building_every_adapter_makes_no_directory in tests/test_analyzers_leave_no_temp_dirs.py, beside test_no_test_makes_a_temp_directory_it_never_removes, which reads every test file.

Mutation: building the registry left four directories, each run left one, and the suite check named five test files.

D236 — Closed: a directory that vanished mid-walk crashed the whole audit (Medium)

Found on 2026-10-30 when the full suite failed in test_readme_claims with FileNotFoundError: '.git/worktrees'. Fifteen walks of the audited tree used Path.rglob, which descends into .git and raises when a directory it listed is gone by the time it opens it. Here another test was adding and removing a git worktree; on a developer's machine it is git itself, an IDE or a build running beside the audit, and one such directory ended the audit with a traceback instead of a report.

_walk.walk is now the only walk of the tree: it never enters .git, never follows a symlinked directory out of the tree, and treats a directory it cannot open as empty. Every former rglob goes through it.

Closing test: test_a_full_audit_survives_a_directory_that_vanishes_mid_walk in tests/test_a_vanishing_directory_never_crashes_the_audit.py, beside test_no_module_walks_the_tree_except_through_the_one_walker, which reads every module of the package.

Mutation: with rglob restored, the audit raised FileNotFoundError on Python 3.11 and, on 3.12, which swallows that error, opened 64 directories inside .git; the structural test named fifteen call sites in seven modules.

Disposition

Every entry is closed. D236 closed on 2026-10-30: fifteen walks of the audited tree used rglob, which entered .git and crashed the audit when a directory vanished mid-walk, as a git worktree does; one walker now skips .git and treats a vanished directory as empty. D235 closed on 2026-10-26: jscpd, complexipy, checkstyle and spotbugs made a temp directory in their constructors and never removed it — 74,535 on one machine, 13.5 GB — and five tests did the same; scratch is now made on first use and removed after every run. D234 closed on 2026-10-26: maintainability.yml read the pull request's base branch on pushes to main, where it is empty, so every push since Decision 18 failed, with a traceback; it now audits the push's own range, and an unresolvable range is refused by name. D233 closed on 2026-10-26, from issue #306: pFUnit's test prefix rule was stripped from every language's file names, so a production file named test… never paired with its test; it now applies to Fortran tests only. D232 closed on 2026-10-15, reported by the operator: --update told him to run /mcp, which in VS Code only lists servers; each MCP server now records its version and --update stops the stale ones that are not mid-audit. D231 closed on 2026-10-15, found by the first run of the pre-push gate: the suite inherited a git hook's GIT_DIR, so its fixture commits and config writes landed in the real repository; the suite now drops git's location variables before anything runs git. D230 closed on 2026-10-15, reported by the operator: the local updater, an untracked script, downgraded 3.7.29 to 3.7.19 by trusting a lagging release feed and never re-synced the skill; --update is now a product command that lets the installer pick the version, refuses a downgrade, re-syncs the skill and flags the MCP restart, and --install-updater schedules it. D229 closed on 2026-10-14, found auditing a private repository: ruff honoured that repository's fix = true and the audit rewrote 102 of its files; ruff and fortitude run with --no-fix, and any analyzer that changes the audited tree now ends the audit with RepositoryModifiedError instead of a grade. D228 closed on 2026-10-12, from a field report on a C# repository: jscpd's findings were listed beside a duplication grade computed from the built-in detector, as designed, and the page never said so; the coverage section now names any analyzer whose findings do not move the grade. D227 closed on 2026-10-09: three private repositories were named in this public one, past a guard that read only added lines against a list nobody derived; every tracked file, path and commit message in a change is now checked against names held outside the tree. D226 closed on 2026-10-04, reported by secure-code-agent's session: pylint, interrogate and cohesion were lost to every audit — an exclude glob pylint could not compile, an exclusion list interrogate read as one path, and a version probe cohesion does not accept — each recorded as something else because the outcome was read from an exit code. A tool that wrote nothing now fails with what it said. D224 and D225 closed on 2026-10-03: a committed docs site was scored as source and adding one exclusion discarded the defaults (#292), and every read failed on Windows for want of O_NONBLOCK. D223 closed on 2026-10-02: cohesion was spawned with no Python to read and recorded as failed on most of the corpus; it now reports not applicable. D222 closed on 2026-10-02, from the audit of 4.0.0: the chat door's scanner preflight, added in 3.12.0, ran a command the audited tree's own config chose before the user had said "run", and the run itself could too through the delegate. By the operator's decision both were fixed at once, here and in secure-code-agent 0.12.10. D221 and D220 closed on 2026-10-02, both found working on the calibration run itself: a fingerprint that missed most of the modules a measurement passes through, so reused corpus rows could come from an older scanner; and analyzers handed a file list that was cut to one command line, so the largest repositories were read in part. The list is now split across runs and the fingerprint is held to the measurement path's import closure. D219 closed on 2026-10-01, found extending the corpus to Kotlin: a symlink into an unfetched submodule ended the audit with D141's refusal. By the operator's decision it is skipped and named. D218 closed on 2026-09-28, from the audit of v3.11.0: a quoted path hid a test file from clean, ordinary skips and any added call slipped past the oracle check, and coverage, --ask and the report save each had an edge the audit found. D217 closed on 2026-09-27, from the drift audit of 3.9.0: an arithmetic shift was read as a heredoc and hid every Shell function after it, and a keyword after do in an argument list counted as a branch. D216 closed on 2026-09-27: the chat door returned the complete HTML report inline, 308,957 characters, which the host refused, because the size fix of 2026-08-31 bounded one field and its tests measured that field. The report is saved where the person chose and the whole reply now has a budget. D214 and D215 closed on 2026-09-27, both from auditing a private application with 3.9.0: consent to run one repository's test command ran it in every repository, because the person's tier held one unkeyed command, and a behaviour suite under acceptance/ was graded as production. Consent is per repository now, and the test-directory list gained acceptance and e2e and became one list with one declared exception. D202 closed by decision on 2026-09-21: the operator ruled that the hostile-audit brief is an unfinished feature rather than a defect — the emitter exists, the half that carries its results into a report does not, and what that becomes belongs to the agentic audit feature rather than to a bug fix. It is a recorded non-goal so no audit re-derives it as an open defect. D213 closed on 2026-09-22, from the full-tree audit and from the operator's own experience of it: the labor rates published a one-item enumeration as a type marker, so the elicitation door read "any number" and a host on the data path read "choose 90" — entering a real senior rate meant contradicting the published contract. The question now says it is a number and both doors ask it rather than inferring from the shape of its options. D212 closed on 2026-09-22, from the full-tree audit: \bfor\b does not match foreach, so C#'s primary loop scored complexity 1 and cognitive 0 against an equivalent counted loop at 2 and 2. The fourth instance of a class fixed three times in one language each — Fortran's do, Swift's guard, PHP's elseif — and now checked over every parsed language, with the disagreement against lizard declared from the C# specification's own definition of the statement. D211 closed on 2026-09-21, from the full-tree audit: three readers took their paths from the audited tree and never went through the boundary config.repository_path applies to configured ones, so a repository whose pyproject.toml was a symlink out of its own root scored practice level 2 against the same tree's 1 — the tree raising its own reported level with evidence this tool was never pointed at. The rule moved down to the reading primitive and the audited-tree readers now go through it. D210 closed on 2026-09-21, from the full-tree audit: practice level is a fact about the repository, stated once above the pillar table, and every row repeated it — so the efficiency row read "practice 4" for a pillar declared out of scope and an unmeasured security row said the same. posture had been nulled for that exact reason and its input left behind. Both axes are absent now for a pillar with no reading of its own, while a delegated pillar that came back with a document keeps the producer's. D209 closed on 2026-09-21, from the same full-tree audit: a duplicate block is a statement about two or more places, and the work order filed it at one of them with the target "remove the duplicated block" — an instruction that clears nothing and breaks the file the copy is deleted from. "Remove" is the verb D165 corrected for risk patterns one class over, and near_duplicate_section already named both sides while the work order named one. Every surface that prints a location now prints the others, from one shared reader. D208 closed on 2026-09-21, hours after being filed Open: _work_order.py sat five lines under its own file gate after D207, the next fix needed ten lines in it, and the prediction that the pressure to pass would become pressure to delete a comment came true in one step. _handoff now holds who-gets-handed-what, _work_order is 491 lines, and the layering carve-out that named the boundary in prose is a module. D207 closed on 2026-09-21, from the same full-tree audit: one prompt printed "Do not attempt them here" over a list of withheld findings and then listed those very targets under "to inspect first", because the focus sections honoured one of withheld_reason's two clauses on three of their seven categories — D180's contradiction on a different axis, in the file that had already fixed it once for risk findings alone. D206 closed on 2026-09-21, from the full-tree audit of 178a642: a pillar's condition is the mean of its measured aspects, so withholding maintainability's worst aspect moved the reading from 4.43 to 5.0 in a row that still listed all seven and said nothing — P3 and P8 in one cell. The overall grade was already defended by grading on the evidence floor and the pillar reading was not; flooring it too would have made an owned pillar's condition incomparable to a delegated one, so the fix is that no skin prints a partial condition without naming what produced it. D203, D204 and D205 closed on 2026-09-20, from an audit of 81caf1b and all three about the same seam: D198 and D200 removed the version chore from the code and left every instruction, note and check describing the world before it. The release process still told a releaser to write the version into two files it had been deleted from, so following the documentation produced a red build; twelve consecutive tags published the placeholder sentence the GitHub Release job substitutes when a changelog section is missing, because this file stopped at 3.7.7; and two version checks verified a proxy for their own claim — a gate whose comment said it checked the wheel while it read a file in the tree, and a one-writer sweep that searched for the current version's characters and therefore could not see a stale copy. D201 closed on 2026-09-20, from the operator asking whether json was in the terminal's questions: --format json had been accepted on both doors, documented in the CLI reference and offered by no question for eleven releases, and ADR 011 had never mentioned it, so the omission could be read as deliberate or as a bug and was read both ways in one afternoon. The presentation tuple is the single source now and both doors read it. D200 closed on 2026-09-20, from reading this repository's own audit: D401 asked 330 times for an imperative voice this project does not write in, and the release version still had to be typed into four documents by hand — the chore that failed v3.7.9. The rule is off with its reasoning recorded and the documents are stamped from the single source, which removes the chore and deliberately not the coupling. D199 closed on 2026-09-20, from an audit of v3.7.16 verified by tests driving the rendered skins: the bounded chat view reported test effectiveness: not measurable for a suite that had run and failed, because it never rendered the suite at all — a value with no attributable source (P8), and a failed measurement misreported as an absent one. D176's fix for the security pillar, one artifact over. D198 closed on 2026-09-20, from this repository's own audit: change_coupling scored 2.0 because the version was written in five files, and the top coupled pair — __init__.py with config.py at 0.985 confidence — was a copy rather than a dependency. v3.7.9 had already failed a release over it. There is one literal now, re-exported and derived rather than restated. D197 closed on 2026-09-19, reported by the operator on sight: the HTML report wrapped the delegate's work order in <pre>, so a reader who chose HTML got raw Markdown inside a rendered page. The delegate now emits the same work order as facts and this tool draws them, in the delegate's own tiers, counts and caps. D196 closed on 2026-09-19, found by running the same configured test command through both doors: the CLI resolved the project's pytest and measured 96% coverage, the MCP server resolved another environment's and could not run the suite at all, and neither result said which program it had executed. The resolved path is now reported beside the configured command. D194 closed on 2026-09-18, found by the operator asking where his HTML report had gone: a per-call format overrode the presentation he chose during setup and the result disclosed nothing, making an agent's preference indistinguishable from the user's. D193 closed on 2026-09-18, from an audit verified by tests driving a real MCP client: setup_answers was published but not finished — the rates arrived as strings and never converted, so the bounds stayed pending forever; an illegal value like depth: "nuclear" persisted the default and reported success; and three surfaces still said "call again" without naming the parameter. Submissions are now checked and converted against the questions the host was actually offered. D192 closed on 2026-09-18, seen in the first staged reply D190 made reachable: the instruction announced that the repository had never been set up, above three labor rates that exist because it had been, recited the presentation options with no such question in the payload, and never named setup_answers. It is derived from the questions now. D191 closed on 2026-09-18, surfaced the moment this repository's own config gained the blocks first-run setup writes: the published schema sets additionalProperties: false and described none of presentation, history, test_execution or economic_context, so a completed setup produced a file the tool's own contract rejected. The gate is now the round trip — write with apply_answers, validate what lands. D190 closed on 2026-09-18, found by using what D189 had just fixed: the answers landed, economics: include opened the second stage, and the data path handed back stage one again because the staging lived only in setup_schema. One stage decision now feeds both renderings. D189 closed on 2026-09-18, found by a user reconfiguring through the chat door: the tool returned its seven setup questions, he answered all seven, and nothing was written — setup is an elicitation-resolved parameter absent from the published schema, so a host that cannot be elicited had no argument to answer with and the documented fallback could not close. D185's shape one door over, on the same day it closed. A published setup_answers carries them to the same writer, and the legal names derive from the questions so an unanswerable question is not expressible. D188 closed on 2026-09-18, found when the MCP server answered a /maintainability-agent call with agent_version 3.7.7 hours after 3.7.8 shipped: the version is a source constant, so a stale process reports its own staleness as fact and nothing can notice. The venv beneath it was an editable install frozen at 1.8.0 carrying secure-code-agent 0.2.0 against a floor of 0.12.7, which would have refused the delegate and unmeasured the Security pillar. The agent now compares loaded code against installed metadata and names which of the two is stale. D187 closed on 2026-09-18, found trying to merge the branch that closed D186: every required check passed and the merge was refused, because the uploaded SARIF was the full record and code scanning had opened a blocking review thread for each of seventeen assert statements in the test files the branch added. Code scanning now gets the filtered document secure-code-agent writes for it, and the record is kept as an artifact. D186 closed on 2026-09-18, found running this repository's own security audit after D184 let the pillar be graded: the grade was held at B+ by 68 true, permanent reports about what this tool is — spawning analyzer subprocesses, reading analyzer XML, calling the SonarCloud API, seeding a calibration bootstrap. The delegate config now declares those four capabilities with a stated reason each, and the findings are reported on their own axes rather than scored, with the undeclared score disclosed beside the declared one. D185 closed on 2026-09-17, found by reading this repository's own report: a person who opted into running the test suite with no command in their tier got a refusal on one surface and silence on the other, and this repository had been in that state for weeks. D184 closed on 2026-09-17, isolated while fixing secure-code-agent's own D23: the delegate config asserted no scanner set, so the security pillar reported unverified on complete coverage and could not be graded; gates.require_scanners now names the scanners the gate job installs. D183 closed on 2026-09-16: an address outside the commit identity reached history through commit messages and file content, which the identity check never read; two authorship steps now refuse it before merge, and the terms they guard live in a secret rather than the tree. D182 closed on 2026-09-14, found closing D179: the supported secure-code-agent range admitted 0.12.1, which ignores reviewed paths: suppressions on the absolute root this audit passes it; the floor is now 0.12.2, where that tool fixed it. D179 through D181 closed on 2026-09-14, from the audit of 3.7.3 (7befdbd): the security pillar counted five critical findings and no skin said so, while secure-code-agent's own work order was deleted unread; a copy-paste block told an agent to make a change the prompt withheld as a design decision; and a full scan kept semantic findings from files the configuration excluded. D178 closed on 2026-09-13, found proving D177 through a fresh MCP server: the setup page's scanner install replaced mcp 2.x with 1.23.3 through semgrep's pin, and the door's startup error called the installed package missing. D176 and D177 closed on 2026-09-13, reported from the ChatGPT desktop app: the chat view and the prompt said nothing about the security pillar, and no audit outside CI ever ran the tool that measures it. The audit now runs secure-code-agent and every skin shows the pillar. D175 closed on 2026-09-13, found verifying the demo loop: a Maven build's output in target/ was scored as jsoup's own code, so one commit audited differently before and after mvn test. D171 through D174 closed on 2026-09-13, from the hostile audit of 77de21c: the pre-commit hook worded a declaration unlike the audit (a gap known at D164 and left unfiled), the prompt attributed a withheld estimate to its sources, a run whose report said "Nothing to do" still handed the agent a patch, and a changed-only run nominated a semantic finding from a file outside the change. D170 closed on 2026-09-13, filed from the ADR conflict D169 surfaced: the exposure sort could lift a Fill-In above a Quick Win, and band is now its primary key. D164 through D169 closed on 2026-09-13, found by running a Java repository's top work item through the chat door to verify remediation: a class described as a function with a bounded fix, a TODO the prompt asked to have deleted, a verify command that addressed whichever python a shell found, a COBOL caveat on a repository with no COBOL, two contradictory corpus counts on one report, and a heading that claimed an order the exposure sort had replaced. None changed scoring, ordering or policy; each corrected what a report stated. D163 and D162 closed on 2026-09-12, from a value-audit rerun on 76cd40d (3.5.0) against the first-five-minutes path that release advertised: the config the README tells a stranger to download opened 10 suffixes where the shipped default opens 48, hiding 11 of 14 parsed languages behind a web subset; and the file that README calls the work order still opened with the grade and printed corpus multiples on a run whose overall the scorer had withheld. D161 closed on 2026-09-11, found by running the demo tree's own documented command: - was a filename rather than stdout on all seven rendered outputs, so the --prompt-output - | agent shape the audit recommends as the five-minute path produced an empty pipe and a file named -. D160 closed on 2026-09-11, from a value audit: the surface most users drive this tool through opened with a fourteen-row metric table, putting the first pasteable prompt on line 51 and the letter grade on line 8 — and a run with an empty backlog, which is what this repository produces, rendered no work-order section at all. D159 closed on 2026-09-11, found by running the README's own quickstart as a stranger would: the bounded work order gave all twelve paste slots to one finding class, six of them naming the same file, while two other classes got none. D158 closed on 2026-09-11, filed retroactively: a stored field called rubric_version held the package version, the displayed half was fixed in 3.2.0 and never written down, and the record went on saying it. D157 closed on 2026-09-11, reported twice by audit: the claim that chat and markdown are one text was fixed in the internal docstring and left standing in the MCP tool docstring a host actually renders, so a finding survived a full cycle. D156 closed on 2026-09-11, found by the secure-code-agent session reporting the class rather than its own instance: **/dir/ excluded nothing at any depth while the bare dir/ excluded at all of them, so a config written in gitignore's spelling silently scanned what it named. D155 closed on 2026-09-11, reported by the secure-code-agent session while confirming its schema had not changed: its scoring had, twice in one day, and MA recorded a delegated pillar's condition without recording who produced it — so the trend would have joined straight across a change neither tool could see. D154 closed on 2026-09-11: the delegate's config excluded almost nothing this repository carries, so the security pillar was computed over 957,219 lines of stored audit output about other repositories and a denominator that size hid five critical findings behind an A-; and the report never disclosed that analyzer children run unsandboxed, which the intent page has always said. D153 closed on 2026-09-11, from the audit of 673e667: authorize_config bounded a config path but never walked its lexical route, so an inward symlink the audited tree planted was accepted at the one door repository_path already refused it at — D34 enforced at half its doors. D152 closed the same day, from the same audit: D147 stripped the repository's request to run the suite and left it choosing the program, because the opted_in_command its own comments cited as the user-tier reader had never been written — so a person's consent to pytest -q executed whatever the tree documented instead, on an unclamped timeout. D150 and D151 closed on 2026-09-10: the interactive terminal asked five of the seven setup questions, never offering the economic scenario; and v3.1.0 was tagged while a known defect sat unfiled, which the release workflow now refuses. D148 and D149 closed on 2026-09-10, both found by the tool auditing itself: a caveat that read "COBOL are parsed" in four places once the unanchored set became one, and a pairing rule that called 52 tested modules untested because their tests are named for behaviour rather than for modules. D143 through D147 all closed on 2026-09-09 from the audit of 39a91b9: a caveat that named five anchored languages, an environment remedy that addressed whichever pip PATH found, nine repository-controlled readers that bypassed the regular-file door, a plus-only diff fragment read as file content on every declaration suffix, and two staged setup writers that copied the audited tree's document into the user tier where acquisition_permitted trusts it.

D145 was filed Open for part of that day because its delivered falsifier could not collect, and closed once the falsifier was rewritten — the entry records both the four defects in it and the change of test author that fixing it required.

D142 closed on 2026-09-09, found by the agent auditing itself over MCP: the analyzer pool was invisible because locate did not search where pip install actually writes on a system Python, and the resulting fallback reported a better grade than the truthful scan. The four from the post-2.11.0 audit closed on 2026-09-08: They are the remainder of the audit of origin/main after 2.11.0 (1704498) through #198, with the 3.0.0 publish claims left for when that release is actually out. D138 is the D133 class still open: a hunk-only diff, the paste an agent actually sends, still reads as clean on every brace language. D139 is the D129 sentence that survived the honest paragraph. D140 is the scoring claim #198 shipped: two counting conventions published as one analyzer rate, which is what 3.0.0 will re-grade on. D141 is the D131 remainder: scan reads were carved out of the primitive without being named as a limit.

D137 closed on 2026-09-07: the declarations criterion-set check was written at repository scope and applied to units, so one Python file let a C repository be scored on two of three criteria. It is now checked per language. That fix changes which repositories use analyzer readings, so it moves the corpus declarations reference and rides the recalibration release rather than shipping alone. D136 is closed by decision rather than by code — --check stays on the CLI for now, recorded as a non-goal so it is not re-derived as an unfinished bug. They are the remainder of the audit of the twenty commits on main after 2.8.0. D125–D129 (the 2.11.0 language findings from that same audit) closed in 2.11.1. D130 closed with it: --sarif-input now reads through read_operator_file, and every CLI option is classified by what it does with a path so a fourth one cannot be added unclassified — the count in prose that D104 relied on is no longer the control.

D131 closed after it: all eight sites across five modules read through the primitive, and the guard now parses every state-file module rather than the two --sarif-input needed. It was briefly marked closed on the strength of the --sarif-input fix alone and reopened — the same mistake D104 made, closing a class from one instance, caught this time before it shipped. D132 closed: cognitive complexity is a named budget now, and a fail no budget names reports no figure rather than a negative one. D133 closed — a piped diff is now refused by its format in every language, where the sentence promising it had been true of Python alone. D134 closed: the two doors share one refusal list now, so the flag-refusal class --staged named cannot drift out of --check again. D135 closed: headroom reports every budget a declaration can fail on, and bands on the worst of them. D136 is the chat-primary gap: --check is CLI-only, and it was decided on 2026-09-06 that it stays there for now. D124 is a release-checklist omission that cost a build and no artifact: the gate held, the tag was re-pointed. D123 is the shipped README contradicting its own table, found by reading the page rather than by any check. D121 and D122 were found by CI on the pull request that shipped the rest, after a green local suite — an empty parameter set reported as a pass, and f-strings unmasked on every Python from 3.12. Both are the same shape as the audit that found them: a check that could not fail, and a fix that reached one interpreter. D112 through D120 record a complexity audit that found this project measuring Python against its own comments, missing its boolean operators, and reading a ? in type position as a ternary in five languages — nine entries, of which D119 is the method failure behind the other eight, and D120 was found by the check D119 built. D110 closed once 2.9.0 put the images on main and both absolute URLs returned 200. D109 closed the hollow test and the gate hole that let it through, and D111 closed the escape phrase that the gate matched inside prose about itself. D108's closing test has landed. D106 preserves both determinism checks with explicit double invocations and failure messages. SonarCloud confirmation awaits CI. D107 records two SonarCloud false-positive resolutions in the same turn they were taken.

Everything before them is closed. D102 closed by splitting the two helpers that were over the cognitive warn line; D101 and D103 closed the day they were filed — D103 by splitting _jobs, D101 by the decision that an unsupported platform is a stated limit and never a reason to weaken the supported ones. All three were filed 2026-09-05, once it was noticed that the rule being broken was the known-defect rule. A Windows probe had produced a High finding about the write-safety path, and two warn-level findings had been resolved in PR threads without being acted on — and all three were reported in a wrap-up as "still open" rather than filed, which is the exact inversion of the known-defect rule. 2.8.0 was then tagged and released with them open, against "no release until the known-defect ledger is empty". The release is not withdrawn; the entries are filed and the ledger gates the next one.

Until 2026-09-05 the ledger was empty. D100 was the last to close, on 2026-08-28: the artifact had promoted itself to a 1.0 release candidate with no recorded authorization, and the entry that did it was the entry written to stop exactly that. It is the fifth round running in which the defect was in a fix rather than in the code the fix repaired, and the first in which the register could not say who wrote the fix.

D89 closed the same day, when the resolve-constraints job produced the Linux closure the gating jobs had been pinned against a macOS resolution of. Its xfail(strict=True) residual became XPASS(strict) on the day the file landed, which is the behaviour that marker exists for: it fails when the situation is fixed, so the fix cannot be taken while the marker is left behind.

This page reached an empty ledger twice and gave it back twice, which is the useful part. An audit observed that the register can be empty while the proof it cites is failing in CI, and that "0 open" was being read as "known good". A closure that does not survive being checked is not a closure.

Where the rest came from. D32 through D46 came from two independent security audits on 2026-08-23 and closed over the two days after; D47 through D49 came from the chat-surface work that preceded them; D50 through D55 came from UAT preparation on 2026-08-25 — one from reading the question set and five from an audit of the whole repository; D56 through D62 came from an audit of the whole repository on 2026-08-26, which also reopened D38. Its verdict was that the register was "an empty ledger, not an empty defect list", and on every finding it was right. D63 came from asking who the product is for, on 2026-08-26. D64 through D70 came from a second round of both audits on 2026-08-26, run against the fixes for the first — four of the seven reopened an entry that had already been closed once, and three of those reopens went through the closing test rather than around the fix.

D70 is the one worth reading twice. The local baseline this project diffs every change against — 101 failures and 191 errors, carried run after run as "known" — was a single line in seven test fixtures pinning PATH=/usr/bin:/bin, which on this project's own development machine resolves to Xcode's stub rather than to git. None of those 292 results said anything about the product, and any real regression landing in those files had 292 places to hide. The discipline of diffing against a baseline was working exactly as designed and was protecting a number that meant nothing. The count is derived from the headings above and checked by test_the_disposition_names_the_entries_that_are_open, which required this sentence rather than an omission — with nothing open, a disposition that simply lists no entries reads exactly like a parser that stopped working. D34 through D37 were filed by the same 2026-08-23 security pass and have since closed; this paragraph named them as open for two days after they were not, which is the register describing a state a reader cannot verify from its own headings. The count is now derived from the headings above rather than carried in prose, and test_the_disposition_names_the_entries_that_are_open fails if the two disagree.

Those four are the reason v1.0 does not cut. Two are the classes this register already paid to close and never applied everywhere: D18 bound the skill installer's writes by descriptor, and config, history and baseline still open by name; D20 bounded paths.history, and class_dirs and expand_files still take unbounded paths from the audited tree. One, D35, is a trust inversion — product-intent.md P1 says a user enables tool acquisition, and a repository config overrides the user tier, so a pull request can make the host run npx --yes.

The lesson is not that the fixes were wrong. It is that each was applied to the seam an audit happened to be looking at. "Close the class" means every door that shares the primitive, and three rounds of chat-surface auditing never looked at the write primitives at all.

D28 and D29 were opened and closed on 2026-08-22 under the standing rule that a release ships only from an empty known-defect ledger; filing them here rather than leaving them in a chat message is what made them countable. D30 and D31 came from the audit of D21–D27 and are the most instructive pair in the register: both are defects in the fixes, found because the fixes were audited rather than trusted. D30 is a precondition that guarded one door of four. D31 is a check that verified a falsifier existed without verifying a reader could find it. D32 is the pair's own audit round: a refusal that read correctly in-process and arrived at the client as an internal error, a CLI that crashed where the chat doors refused politely, and a title that claimed more than its fix delivered. D33 is the round after that, and the pattern held a fourth time: a check that verified a citation named a test, satisfied by the filename in the path; a refusal translated on one layer of two; two parsers reading one file to opposite conclusions.

Four rounds running, every defect has been in a fix rather than in the code it repaired. That is not a run of bad luck. A fix is written by someone who has just convinced themselves they understand the problem, and it is tested by someone in that same state — so the test tends to assert the thing the author was already thinking about. Auditing a fix as hard as the thing it repairs is the only step that has reliably caught this, and every round of it has paid.

Every closed entry sits behind a test that would fail if its defect returned, and since 2026-08-22 that citation is machine-checked — three entries had been closing on tests a rename had deleted. The count is read from the entries themselves, never asserted as a number that stops describing the register when it grows. D15 was reopened twice: once when an audit found its close had rewritten the requirement, once when the proof turned out to be vacuous. D18 and D19 were each reopened after their first close, when audits reproduced a descriptor race, a hard-link write-through, a short write, and three kinds of occupancy the installer ignored. D20 was found by audit in the MCP write boundary and is the one security defect in this register: a repository could name a history path outside itself and be believed. D21 and D22 came from the same field run and are the same shape: an instruction surface that left a gap, and an agent that filled it by improvising a question the tool already asks. D23 is the most consequential entry here — nine releases shipped with no analyzer catalog inside them, so every installed copy lost the analyzer pool while a green suite, running from a checkout, reported everything working. D24 is how that outage stayed quiet: the envelope reported the request rather than the result. D25 corrected D22's diagnosis rather than extending it — the presentation step was a real gap, but the reason a user was never once offered the html report is that the question offering it was handed back as data no instruction surface mentioned. Three entries in this register are the same sentence: the product produced the right thing and nothing told the agent to use it. D26 is the one that stopped treating that as a documentation problem — the audit no longer runs at all until the repository has been set up, so there is no premature grade for an agent to report in place of the questions. D47 is the entry that had to be corrected after it was written: it was opened as the cause of a field report and was not the cause. D48 is the one found by a machine rather than a person — an unchanged main went red when a dependency shipped, exposing a misclassification the product had been shipping since its first MCP release — and the anticipated set was wrong twice in opposite directions before a derivation test owned it. D49 is D23's hole on the skill payload; it is not the cause of the stale installed skill that prompted the look.