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:
- 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."
- An audit finds one instance where the claim is false.
- The check is written from the instance, not from the claim.
- 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_reportand--backfillstill 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:
- S1. A dangling
maintainability-agent.jsonsymlink pointing outside the repository.setup_pendingreadsTrue(is_file()is false on a dangling link), andapply_answersthen writes 205 bytes of configuration through the link. The repository path remains a symlink._first_run._persistshares the primitive, and reconfigure re-enters the same function. - S2.
.maintainability/history.jsonlhardlinked to a file outside the repository.repository_pathaccepts it — resolution andis_relative_tobound the name, never the inode — andappend_scanappends onto the outside file. - S6.
write_baseline=Truewithbaseline_path="README.md"truncates source. The MCP description anddocs/architecture.mdboth promise five artifacts and "never source"; the boundary check is only "inside the granted root", and the model supplies that argument on the primary surface.
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.
- S4.
analyzers.class_dirsreaches_target_dirsasroot / relative, andPath("/tmp/repo") / "/"is/. Confirmed here: a repository config naming/yields/as a scan target, which is also anrglob("*.class")over the filesystem on an MCP child with no timeout. - S9 / C1.
expand_filesand the built-in scan both useis_file(), which follows symlinks. The C audit proved the sharper half: a repository containinglinked.py -> ../outside.pyhad that file read, measured, and returned in the report —TOP_SECRET_VALUE = 42inside a findings payload.expand_filesis worse still, because its output becomes analyzer argv.
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.
has_history's third probe still read a failed shallow check as "not shallow" —probe_git(...) != "true"— so a git that cannot answer the question reported complete history. That is the sentence D37 opened with, still implemented three lines below the two probes that were fixed. It now fails closed: only an explicit"false"establishes completeness.rename_mapprobed its diff, so a timeout or an unreadable object became "no renames" and agit mvsurfaced every moved finding as new — the ADR 009 hole, produced by the spawner. The two legitimate empty cases (a commit git no longer has, equal commits) are established by probing for the commits first; the diff itself is strict.- The
require_clean_worktreegate read a failedgit statusas an empty one, so a plain directory satisfied it.worktree_statusreturnsNonefor "not a worktree", which the gate now fails on rather than passing.
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.jsonto 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 installedv0.9.1wheel, whose step 1 reads "Configuration check first: look formaintainability-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.
D72 — Closed: a refusal does not disclose where a symlink points (High)
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.
D82 — Closed: the audit door stops naming symlink targets too (High)
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."
config.py—--configwas read straight intojson.loads.baseline.py—--baselinelikewise.
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:
tests/test_three_presentations.py:116—assert render_html(report, records) == render_html(report, records)tests/test_evidence_properties.py:476—assert sweep() == sweep()
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: ignorein 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.
- C#
int? v— a nullable type - TypeScript
title?: string— an optional parameter - Java
List<?>— a generic wildcard - PHP
?int $v— a nullable type hint - Swift
Int?— an optional
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:
_first_runlabour / test-command persist:is_symlinkthenexiststhenread_textonmaintainability-agent.json_user_config._read_json_object: no check at all on the XDG config_mcp_audit._refuse_clobbering_non_baseline:repository_pathaccepts an in-tree FIFO, thenread_texthangs on MCPwrite_baseline=Truewrite_boundedappend and_refuse_nonjson_clobber:is_filethenread_text, so a swap to a FIFO between the check and the open is the TOCTOU those helpers were written to close
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:
cognitivejoins_DECLARATION_BUDGETS, so the budget that failed can be named.- A threshold a configuration does not set is no budget, not a
budget of zero. Indexing
max_cognitive_complexityunconditionally would have turned this fix into aKeyErrorfor anyone running a trimmed config. - The fallback stops inventing a figure. When no budget matches it
reports
over_by: None, and the renderer says "over its budget" rather than "None over". The finding still reports —function_statusfailed it and a reader needs to know — without a number it cannot justify.
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
--checkis 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:
bandis the worst across budgets rather than the line band. That is what makes text speak, and JSON honest, for a short complex function.limitandremainingstill mean the line budget. They shipped meaning that, and redefining them as "the worst budget" would change an existing field's meaning under consumers already reading it. The per-budget detail is a newbudgetslist beside them.
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:
- It is not a per-language blend. The value returned replaces the built-in
declarationspressure for the whole repository, and there is no seam to hand back a mixture — the built-in path carries one aggregatedeclaration_band_pressure, not a per-language one. A real blend needs per-language aggregates plumbed through the summary evidence, which is its own change. The honest intermediate is to fall back for the repository when any language it would speak for is incomplete: strictly more correct than today, and it invents no number. - Unmapped suffixes gate nothing. A language that was never established
cannot be missing a criterion. Pooling every unmapped suffix into one bucket
and demanding all three of it is the obvious wrong fix:
pmdreports complexity forbeans.xmland no declaration lines, so that bucket is permanently incomplete and every Java repository with a Spring config would fall back.
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:
docs/roadmap.mddescribed six unanchored languages in the present tense in six places after 3.0.0 anchored five — including a Next entry naming work that had already shipped, a corpus-policy section still saying "Swift … is unanchored today", and a paragraph calling the corpus-extension effect "still unmeasured" two paragraphs below the release that measured it.docs/standard.mdand its packaged copy opened the corpus section with "40 mature open-source repositories … across Python, TypeScript and JavaScript" and corrected themselves to 180 across thirteen four paragraphs later. A reader met both numbers on one page with nothing to say which was current. Now 180, 24–18,789 source files, 2,749,575 declarations, all derived fromcorpus.jsonrather than retyped.
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:
test_every_tree_read_uses_a_regular_file_primitiveassertedreadsand thennot readson the same list. No state of the code satisfies both. The non-empty clause the falsifier standard requires belongs on the population a sweep walks, never on the violations it finds — the standard's own clause applied to the wrong list.test_discovery_refuses_metadata_fifos_without_blockingderived its parameters from "functions in_discovery.pythat callread_text", which is the defect. Fixing the defect emptied the parameter set and the module stopped collecting withEmpty parameter set. A check that can only run while the bug is present says nothing about its absence.- Its sweep flagged every
read_text/openin the package, including_catalogreading the packaged catalog and_skill_installreading its own payload. Neither is a repository-controlled path. _metadata_nameswas the whole tree's only hard-gate failure at cognitive 24 in 20 lines, and carried lint errors besides.
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_commanddid not exist. It was named here, and in two more docstrings, as the reader that made keepingexpected_commands.testin the repository document safe — and nothing implemented it.run_test_suitewent 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.
D152 — Closed: D147 kept the consent and handed back the choice of program (High)
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:
_finish_resultsaid chat and markdown are "on the wire the two are the same text" while the line beneath it already passedcomplete=(format == "markdown"). They are two skins — chat bounded for a host's payload cap, markdown the complete report.- the HTML skin carried none of the coverage gap notes, "Nothing examined" included, because the prose was built as Markdown strings inside the Markdown renderer. ADR 011's claim that the skins never disagree was resting on nobody checking (P8).
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:
Maintainability estimate: Not scored- then
declarations at 3.9x/duplication at 3.18xandStart with declarations
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'smcp<2pin beside this package'smcp>=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:
- Commit messages name only the commit identity. Any address in a commit message, merges included, other than the repository's commit identity fails the pull request. It is an allowlist, so it catches a trailer for any address rather than a list of known ones.
- No forbidden term in the change. Terms that must never appear live in a repository secret, not in the tree, so the check does not publish what it guards. It reads each commit's identity fields and message and every added line, reports a match by commit or file without printing the text, and fails closed when the secret is unset.
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.
D185 — Closed: consent to run the test suite, with no command recorded, was a dead end no surface named a way out of (Medium)
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:
- loaded code behind the metadata is a stale process, and the remedy is to restart, not reinstall;
- loaded code ahead of it is a frozen editable install, and the remedy is to reinstall, because restarting changes nothing.
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:
- the terminal —
"Report format — chat (Enter), markdown, or html: ", - the MCP
default_formatsetup question, whose options were a hand-written["chat", "markdown", "html"]rather than the tuple the terminal reads, - and therefore the skill and the server prompt, which derive their wording from that tuple and were correct about a set that was wrong.
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.
- It is in no document a user reads. README: no mention. Both
SKILL.mdcopies: no mention.docs/product-intent.mddiscusses 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. - 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.
- 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.
D214 — Closed: consent to run one repository's tests ran them in every repository (High)
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:
- A quoted path was invisible. Git quotes a path holding a space, a
quote or a non-ASCII byte (
--- "a/my test.py", octal escapes), and the reader expected the bare form, so the whole file was dropped andcleannever saw a test weakened in it. Headers are now unquoted and decoded. - Ordinary skips were not skips:
skipif,skipIf/skipUnless,pytest.skip(),xit(,test.skip(. And any added call excused a deleted assertion —print(...)included. Only a helper-shaped call (a private name, or one that checks) does now. - A deleted test was misnamed "assertion deleted". It is reported as
test deletedand still failsclean. Removing one of two identical assertions is read as a deletion — declared, because the diff cannot see the line that stayed. --ask: a glob matching nothing is listed underask_unmatched, and an ask naming nothing is a usage error rather than a traceback. That*crosses directories and a paired test stays in scope is now written where the flag is described.- Coverage followed a symlinked
coverage/directory, which the module said it refused; and a malformedcoverage.xmlraised the XML parser's ownParseErrorstraight through the handler. Every step of the route is checked, and an unreadable artifact falls through to the next. - The report save could create the repository's config, history or baseline file when it did not yet exist. Those locations are refused.
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.
D219 — Closed: a symlink to nothing ended the audit (Medium)
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.
- pylint analysed nothing. The operator's exclude names (
*.min.js,*.egg-info/) were handed to--ignore-paths, which pylint compiles as regular expressions;*...is "nothing to repeat", so pylint exited with an argument error. That exit is 2 — also pylint's "error messages" bit — so the run counted as having worked, and its empty output was reported as unreadable JSON while stderr held the reason. Names are now translated for every regex-dialect adapter (pylint, mypy). - interrogate analysed what it was told to skip. Its
--excludenames one path per flag, and the adapter joined every pattern into one space-separated argument that named no path. In secure-code-agent's tree that left a cloned corpus in scope, and one Python 2 file in it crashed interrogate before it printed a percentage. Each plain path is now its own--excludeunder the root; wildcards, which name no location, are left to the scan's own filter. - cohesion was never asked to run. Its availability probe was
cohesion --version, a flag it does not have, so argparse exited 2 and it was recorded as failed — while its comment said availability came from package metadata. It now probes with--help.
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.