ADR 0156 — ASVS scorecard as data: derived count, verified evidence anchors, fail-closed drift gate - #120
Merged
Merged
Conversation
…sting 23M tokens The ASVS score is maintained as prose and nothing checks it. One re-anchoring session on 2026-08-01 produced, in a single day: 6 re-derivations of the headline count from scratch 12 residuals of record factually FALSE at HEAD 5 of those were ABSENCE claims that had silently stopped being true 10 cells missing from an enumeration called "arithmetic-checked and complete" 5 documents asserting a superseded count as current 4 register-vs-scorecard contradictions ~23M subagent tokens spent verifying Most of that was not discovery. It was re-deriving facts already known but never durably recorded, then re-verifying them because the recorded version had gone quietly false. The dropped-ten defect is the instructive one: it survived because the arithmetic closed to 345 and closure was read as proof. Closure only proves the four buckets sum -- not that every cell landed in one. No self-consistent count can detect an absent cell. Decision: one [[cell]] record per requirement for all 345; the count is COMPUTED so no document can state one; a test asserts every corpus id appears exactly once; each cell's evidence anchor names a TOKEN that CI asserts still resolves, so code movement reds a test instead of rotting a sentence; an absence claim must record the search AND a positive control that must still hit; `unverified` is a first-class verdict so inherited-vs-verified Pass is countable; and the verifier fails closed rather than skipping. Tool and schema here (public CI, fixture data); real scorecard in the vault with a vault-CI job. That placement closes ASVS 15.1.3 as a by-product -- six *_doc_drift modules currently assert against documents that `git ls-files docs/security/` shows are absent from the tree where CI runs, so they are inspection-only theatre. States its costs plainly, including that it does NOT make the score correct -- only consistent, derived and drift-detecting. Number allocated atomically via scripts/coord/alloc.ps1; index row in this commit.
…il-closed (ADR 0156) The tool half of ADR 0156. Stdlib only (tomllib/json/re), no new dependency, and deliberately data-free: it takes the scorecard and the ASVS corpus as paths, so this repo unit-tests it against fixtures while the vault runs the same code against the real posture data. What it enforces, each aimed at a defect observed on 2026-08-01: check_completeness -- every corpus id appears EXACTLY ONCE and nothing outside the corpus appears at all. This is the check whose absence cost ten cells: an enumeration closed to 345 and closure was read as proof. Closure only proves the four buckets sum, not that every cell landed in one. count() -- computed, never typed. Five documents asserting three counts is what this replaces. check_anchors -- each cell's evidence names a TOKEN that must still resolve within a window of the recorded line. Code movement reds a test instead of rotting a sentence. That is the twelve-false-residuals defect. check_absences -- an absence claim is admissible ONLY with a positive control that must still match. A grep naming the wrong token returns zero and reads exactly like proof; five residuals of record died that way. If the control goes quiet the search is BLIND and the claim is void regardless of the pattern. unverified is a first-class verdict, so an inherited Pass cannot hide inside a headline. The renderer reports verified-vs-inherited separately. Fail closed: a missing scorecard EXITS 2 (could not measure), never 0. That distinction is the whole of ASVS 15.1.3, where six *_doc_drift modules skip because their target is not in the tree where CI runs. 17 tests, and every check is proved to go RED before it is trusted green -- dropped cell, duplicate cell, an id ASVS retired, a level disagreeing with the corpus, a moved token, a deleted file, a blind absence search, an absence that became false. Exit codes verified by hand: 0 clean / 1 findings / 2 missing. ruff clean, ruff format clean, mypy clean.
…e was false Accepted 2026-08-01, built and merged the same day. Section 7 originally proposed "a vault CI job runs the verifier against the real data", reasoning from the vault having ci.yml, tests/ and pyproject.toml. Open question 1 asked whether that CI actually executes. It does not. The actions API reports EVERY vault workflow as disabled_manually -- CI, Security, CodeQL, backlog-hygiene, release. Last run 2026-07-27, and the two vault PRs merged on 2026-08-01 both merged with zero checks. The estate was switched off at the cutover to avoid duplicating public CI; that decision stands and is not being reversed here. A CI-only design would have shipped dead. Amended to what was actually built: a vault pre-commit hook (works today, fires at authoring time, when the drift is introduced) plus one narrow new workflow -- new workflows are active by default even though the estate is disabled, so it runs without resurrecting any of it. The lesson is this ADR's own thesis applied to itself: section 7 was a confident, plausible statement about system state that nobody had checked. It was caught only because ratification was gated on answering the open question rather than on the argument reading well.
…tuted away The row was written through a shell double-quoted string, so bash treated `disabled_manually` as a command substitution and replaced it with the empty string. The row shipped reading "every vault workflow (last run 2026-07-27...)" -- the load-bearing word silently deleted. Fitting failure mode for this ADR: the sentence still parsed, still read as plausible, and stated nothing. Caught by grepping for the token rather than for the sentence around it.
wshallwshall
enabled auto-merge (squash)
August 1, 2026 14:10
… a spec
Grounded in a deep-research pass over OWASP's own assessment chapter, NIST
OSCAL, and the one comparable OSS tool. Three findings changed the design.
FINDING 1: "Partial" is not an ASVS verdict. ASVS 5.0 defines exactly three --
verified, exception, and non-applicable-with-rationale. The strings "partially
implemented" and "not implemented" appear NOWHERE in it, and the only prominent
OSS ASVS tool declines to model partial too. Our three-way rubric was a local
extension we invented and never wrote down, which is precisely why 11.7.1, 3.7.3
and 5.4.3 each changed verdict in one day: two assessors, two unwritten rules.
-> docs/ASVS-ASSESSMENT-METHOD.md now states the decision procedure as an
ORDERED rule list with the seven real disputes as worked examples, so the
next assessor reaches the same answer. ASVS's own stability mechanism is a
disclosed repeatable method, not a rubric -- this is that disclosure.
FINDING 2: recording the reason for non-applicability is the ONE "must" in the
whole assessment chapter (everything else is "should"). So an `na` with no
rationale is not a lax entry; it is the single thing the standard requires,
omitted. load_scorecard() now REFUSES it.
FINDING 3: ASVS requirement ids are NOT stable across versions -- bare 1.2.5 is
Architecture in 4.0.3 and Encoding in 5.0.0 -- and master is the bleeding-edge
branch where a rolling "latest" release republishes identical filenames. Our
corpus was fetched from master and turned out byte-identical to the tagged
release: luck, not method. Now pinned by [scorecard].asvs_version plus a
corpus_sha256 the verifier recomputes and compares.
PHASE 0 -- the renderer no longer publishes a headline count. It leads with how
much of the survey is done. 76% of that day's verdict changes were cells nobody
had ever opened, so a count computed over unexamined cells is an average of
guesses, and publishing it is what made every first look read as a REVERSAL
rather than as PROGRESS. Reporting every requirement including `unverified` is
also what ASVS asks for -- a summary of all requirements checked, not exceptions
only.
Also adds `needs-review` (examined, contested, parked deliberately) and
`reviewed_by`. Separating "undecided" from "decided-and-failing" is a cheaper
stability primitive than inventing a grade -- borrowed from the one OSS tool in
this space, which pairs it with reviewed_by/reviewed_at.
OSCAL was evaluated and rejected for this use: ASVS has no OSCAL catalog
(proposed 2020, closed 2021 in a backlog sweep, still none in the 2026 release),
and assessment-results mandatorily imports an assessment-plan which mandatorily
imports an SSP -- a chain a product with no operated system has no source for.
Recorded in the method doc, with Component Definition and CycloneDX declarations
noted as the two unexplored alternatives.
24 tests (17 + 7 new), ruff clean, mypy clean.
The workflow rendered with --anchor-sha ${{ github.sha }} while the committed
entry point carried the code anchor, so the rendered file differed on the anchor
line on EVERY run. `git diff --exit-code` therefore failed unconditionally.
A gate that cannot go green is as useless as one that cannot go red, and this
session has spent all day on the second kind. I shipped the first kind, in the
tool built to prevent exactly this, and it went red on its own first PR.
The anchor is the commit the EVIDENCE was read on -- a property of the
assessment, not of the CI run -- so it belongs in [scorecard].anchor_commit and
is rendered from there. The render is now deterministic: running it twice
produces identical bytes, and the drift check becomes meaningful, catching a
scorecard edit that was not re-rendered.
The ASVS 11.1.3 discovery gate caught scripts/asvs/scorecard.py using hashlib without an inventory entry. The gate is right: it is a discovery gate for exactly this, and it fired on the first commit that introduced new crypto usage. Declared in both places the convention requires -- the scanner INVENTORY and ASVS-L2-PHASE0-CHANGES.md section 4 -- with what it is and, more importantly, what it is NOT: SHA-256 over the ASVS corpus FILE, integrity of a build input, no secret, no key, no message authentication, nothing user- or PHI-derived. Recorded why it exists rather than just that it does: the corpus was originally fetched from master, where a rolling "latest" release republishes identical filenames, and matched the tagged v5.0.0_release asset only by luck. Since ASVS requirement ids are not stable across versions -- bare 1.2.5 is Architecture in 4.0.3 and Encoding in 5.0.0 -- a corpus that moves silently re-points every id in the scorecard, so the digest is now checked rather than assumed.
…ries The crypto inventory is stated in FOUR places, not the two I updated: scripts/security/crypto_inventory_check.py INVENTORY (done) docs/ASVS-L2-PHASE0-CHANGES.md section 4 (done) tests/test_key_usage_scope_inventory.py key/not-key split (missing) tests/test_security_static.py out-of-package map (missing) Both now carry it, classified as NOT key material with the reason: a keyless content hash over a build input, not a key, a secret, or a message authenticator. Worth recording rather than quietly fixing. This is the same defect class the whole ASVS effort has been about -- one fact stated in four places, so updating two leaves the other two stale -- and the project's own doc standard says state a load-bearing fact ONCE and link to it. Here the redundancy is load-bearing on purpose (each registry asks a different question: what uses crypto, why, does it hold a key, and is it inside the package), so the fix is not to collapse them. But nothing tells an author that adding one row obliges four edits, and I found out by reding CI twice. 102 tests pass across all five crypto/scorecard suites.
wshallwshall
disabled auto-merge
August 1, 2026 20:36
wshallwshall
added a commit
that referenced
this pull request
Aug 2, 2026
The ADR-0154 session re-ran #119's windows-2025 leg on the SAME commit against the SAME 26:00 cap. Attempt 1 was killed at the cap; attempt 2 concluded success. Same code, same config, same ceiling, two outcomes. This closes the one gap in the case for raising the cap: it rules out "that PR's tests are just slow". The leg was not failing, it was coin-flipping against the ceiling -- which is precisely the state the ubuntu note above already names, now demonstrated rather than argued. It also disposes of "re-run it and see" as a diagnosis. A green re-run at 26:00 does not show the suite fits; it shows that runner was fast enough that time. Recorded in the comment so the next person reaching for a retry knows what a green retry does and does not prove. Evidence contributed by the session holding #119, which is deliberately holding its branch update until this lands so it re-rolls under the raised cap rather than spending another coin flip at 26. Merged main (8f01cef, #120) in the same push: #131 had gone BEHIND, which is the stall this PR exists to report -- the fix for the cap has to survive the cap, and the fix for silent stalls can itself stall silently.
wshallwshall
added a commit
that referenced
this pull request
Aug 2, 2026
…s that can never merge (#131) * ci: report pull requests that are green, armed, and can never merge Measured on this repo 2026-08-01: nine open pull requests with zero failing checks and zero pending checks, not one of which could merge. Six had auto-merge ARMED, which will never fire. #74 had been in that state since 2026-07-30 and was found only because somebody went hunting for "stuck CI" by hand. The mechanism is that `strict = true` plus no merge queue plus a ~20-minute suite makes merging a race: a PR is mergeable only between going green and the next thing landing on main. Losing that race is silent. Armed auto-merge does NOT update a BEHIND branch -- it waits on checks that already passed -- so the PR sits with no failing check, no notification, and no run in flight. No existing signal can see it, because every existing signal is a check OUTCOME and nothing has failed. nightly-notice.yml watches CI runs and there is no failing run to watch; the author's last signal was a full pass. A green dashboard and a wedged repository are indistinguishable unless something asks "can this still merge at all?". This does not fix the race -- only a merge queue does, filed separately as BACKLOG #340. It converts a SILENT failure into a LOUD one, which is the part that let #74 sit for days. Scheduled rather than per-PR: the stall arrives when a DIFFERENT pull request merges, so the affected PR has no run to hang a check on. Advisory by placement and must never become required -- it reports on OTHER pull requests, so a stall on #71 would block #128, wedging the repo with the tool meant to unwedge it. Verified against the live repo: 14 scanned, 8 stalled, 6 armed, exit 1. The count differs from the hand survey's 9 because #120 was re-synced in between, which the check correctly excluded. Tests carry a positive control (the exact stall shape MUST be detected) alongside negative controls for failing, pending, BLOCKED, DIRTY and closed PRs, and assert that an unclassifiable rollup node counts as unsettled rather than green. * ci: raise the Windows step cap, which had 1.06x margin while claiming 2x PR #119 was killed at 26:07 against ci.yml's 26:00 `step_timeout` with ZERO tests failing. What moved was the suite, not the code under test: #74 landed tests/test_worktree_prune_merged.py (1,506 lines) and windows-2025 went 19:35 -> 26:07 on the same branch. The comment beside the cap said the Windows legs were "unchanged because 26 min against the same suite is still ~2x headroom". Measured over the 11 PASSING windows-2025 runs on 2026-08-01: leg max passing step old cap old margin ubuntu-latest 12:27 19:00 1.53x windows-2022 18:39 26:00 1.39x windows-2025 24:35 26:00 1.06x windows-2025 had already PASSED at 24:35 -- 85 seconds of margin -- before #119 died. The "2x" figure matched no leg when it was written. The same file records this exact failure happening on the ubuntu leg on 2026-07-31 (775s green against a 780s cap) and concludes a watchdog that cannot separate "deadlocked" from "slow today" becomes a coin flip; ubuntu's budget was raised then and Windows was left alone on the false claim. Raised to step_timeout 36 (1.46x over the 24:35 max) and job_timeout 40, preserving the nesting invariant that the step must expire strictly before the job. Both Windows legs take the same number: windows-2022 is faster, so sizing on windows-2025 only leaves it more room. The replacement comment states the measured value and its date rather than a multiple -- a bare multiple is what let this rot undetected. Timing note recorded in the comment because it cost two sessions an error during triage: step_timeout gates the STEP, not the job. c53f752's JOB ran 28:41 and PASSED, against job cap 30 / step cap 26. Also files BACKLOG #340 (enable a merge queue -- 9 green PRs could not merge, 6 armed and never firing) and #344 (fixed wall-clock bounds as a class, with this cap as instance 1 and test_stage_dispatcher.py's hardcoded 8.0s poll budget against an injected ManualClock as instance 2). COORDINATION: ci.yml and docs/BACKLOG.md were each held by another live session, and the collision gate (scripts/hooks/collision_gate.ps1) refuses an Edit while a live session's BRANCH carries a diff to the file -- it cannot represent "coordinated, verified disjoint". Both counterparties gave explicit written consent before these edits were applied outside the Edit tool: zizmor-1280-adoption ("You land it. I'm standing down on ci.yml timeouts", its only hunk being a one-line pin comment ~160 lines away) and ha-construct-pickle-sandbox ("Go ahead with #340 now -- append after #338 exactly as you planned. I'll absorb the conflict"). No hooks were skipped; this commit ran the full pre-commit suite. * ci: record that #119's leg passed on a re-run at the same cap The ADR-0154 session re-ran #119's windows-2025 leg on the SAME commit against the SAME 26:00 cap. Attempt 1 was killed at the cap; attempt 2 concluded success. Same code, same config, same ceiling, two outcomes. This closes the one gap in the case for raising the cap: it rules out "that PR's tests are just slow". The leg was not failing, it was coin-flipping against the ceiling -- which is precisely the state the ubuntu note above already names, now demonstrated rather than argued. It also disposes of "re-run it and see" as a diagnosis. A green re-run at 26:00 does not show the suite fits; it shows that runner was fast enough that time. Recorded in the comment so the next person reaching for a retry knows what a green retry does and does not prove. Evidence contributed by the session holding #119, which is deliberately holding its branch update until this lands so it re-rolls under the raised cap rather than spending another coin flip at 26. Merged main (8f01cef, #120) in the same push: #131 had gone BEHIND, which is the stall this PR exists to report -- the fix for the cap has to survive the cap, and the fix for silent stalls can itself stall silently.
wshallwshall
added a commit
that referenced
this pull request
Aug 2, 2026
… not deliver `docs/adr/README.md:121` said ADR 0087 "Closes the WP-L3-17 residual (residual-closure)" while `:175` (ADR 0144) said "15.2.5 stays **Partial**". They contradicted, and :175 was right. Two separate reasons the closure claim was wrong, and only one of them is fixed: 1. Until BACKLOG #339 the IPC pipe pickled, so a Handler's `__reduce__` executed in the engine parent and the boundary was bypassable outright. The row asserted a closure the transport did not deliver. That part is now fixed. 2. Even with the codec, ADR 0087 confines the ADDRESS SPACE, not the host. Verified rather than inferred: `DEFAULT_FORBIDDEN_MODULES` blocks socket/ssl/asyncio/ multiprocessing and the engine's secret-bearing packages, but NOT `os` or `subprocess` — a sandboxed Handler still reaches host command execution. OS-level default-deny is ADR 0147, still Proposed with no code. So the row now records the MFW2 amendment, states 15.2.5 stays Partial in agreement with the 0144 row, names the address-space limit explicitly, and adds the address-space-only and #342 (grandchild not reaped) residuals. Module list gains `_sandbox_codec.py`. This edit was blocked earlier by the worktree guard while `claude/adr-asvs-scorecard-as-data` held the file; it merged as `8f01cef8` (ADR 0156, PR #120) and the guard released. Their merge did not touch the contradiction. NOT included: BACKLOG #339's OPEN item 2 still reads "the edit was NOT made", and #197's banner carries the same original closure claim. `docs/BACKLOG.md` is currently held by two live sessions (adr-0154-sync-reply-handoff, stuck-cis) — I handed that file to the first of them earlier and am not overriding the guard to take it back. Both are text-accuracy follow-ups, not correctness ones.
wshallwshall
added a commit
that referenced
this pull request
Aug 2, 2026
… not deliver `docs/adr/README.md:121` said ADR 0087 "Closes the WP-L3-17 residual (residual-closure)" while `:175` (ADR 0144) said "15.2.5 stays **Partial**". They contradicted, and :175 was right. Two separate reasons the closure claim was wrong, and only one of them is fixed: 1. Until BACKLOG #339 the IPC pipe pickled, so a Handler's `__reduce__` executed in the engine parent and the boundary was bypassable outright. The row asserted a closure the transport did not deliver. That part is now fixed. 2. Even with the codec, ADR 0087 confines the ADDRESS SPACE, not the host. Verified rather than inferred: `DEFAULT_FORBIDDEN_MODULES` blocks socket/ssl/asyncio/ multiprocessing and the engine's secret-bearing packages, but NOT `os` or `subprocess` — a sandboxed Handler still reaches host command execution. OS-level default-deny is ADR 0147, still Proposed with no code. So the row now records the MFW2 amendment, states 15.2.5 stays Partial in agreement with the 0144 row, names the address-space limit explicitly, and adds the address-space-only and #342 (grandchild not reaped) residuals. Module list gains `_sandbox_codec.py`. This edit was blocked earlier by the worktree guard while `claude/adr-asvs-scorecard-as-data` held the file; it merged as `8f01cef8` (ADR 0156, PR #120) and the guard released. Their merge did not touch the contradiction. NOT included: BACKLOG #339's OPEN item 2 still reads "the edit was NOT made", and #197's banner carries the same original closure claim. `docs/BACKLOG.md` is currently held by two live sessions (adr-0154-sync-reply-handoff, stuck-cis) — I handed that file to the first of them earlier and am not overriding the guard to take it back. Both are text-accuracy follow-ups, not correctness ones.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The ASVS score is maintained as prose and nothing checks it. One re-anchoring session on 2026-08-01 produced, in a single day:
Most of that wasn't discovery. It was re-deriving facts already known but never durably recorded, then re-verifying them because the recorded version had gone quietly false.
The instructive one is the dropped ten: it survived because the arithmetic closed to 345 and closure was read as proof. Closure only proves the four buckets sum — not that every cell landed in one. No self-consistent count can detect an absent cell.
What this adds
scripts/asvs/scorecard.py— stdlib only (tomllib/json/re), no new dependency, and deliberately data-free: it takes the scorecard and the ASVS corpus as paths, so this repo unit-tests it against fixtures while the vault runs the same code against real posture data.unverifiedis a first-class verdictOn absence specifically: a grep naming the wrong token returns zero and reads exactly like proof. That's how five residuals stayed false for weeks. So the schema stores the search, not just its conclusion — and if the positive control goes quiet, the claim is void regardless of what the pattern returns.
Tests
17 tests, and every check is proved to go RED before it is trusted green — dropped cell, duplicate cell, an id ASVS retired in 5.0, a level disagreeing with the corpus, a moved token, a deleted file, a blind absence search, an absence that became false.
Exit codes verified by hand: 0 clean · 1 findings · 2 could-not-measure.
ruff check/ruff format --check/mypyall clean.§7 was amended at ratification — the premise was false
The ADR originally proposed "a vault CI job runs the verifier against the real data." Open question 1 asked whether that CI actually executes. It does not — every vault workflow is
disabled_manually, last run 2026-07-27, and both vault PRs that day merged with zero checks.A CI-only design would have shipped dead. Built instead as a vault pre-commit hook plus one narrow new workflow (new workflows are active by default even though the estate is off).
That §7 was a confident, plausible, unchecked claim about system state is this ADR's own thesis applied to itself. It was caught only because ratification was gated on answering the open question rather than on the argument reading well.
What this does not do
It does not make the score correct — only consistent, derived and drift-detecting. A wrong verdict recorded carefully is still wrong; adversarial verification remains the only cure for that, and this ADR doesn't replace it.
The companion vault PR carries the real scorecard and the enforcement.
🤖 Generated with Claude Code