Skip to content

asvs(verifier): anchor form, a ref-stamped --status, sym/ctx validation, and counters that reconcile - #303

Open
wshallwshall wants to merge 6 commits into
mainfrom
asvs-verifier
Open

asvs(verifier): anchor form, a ref-stamped --status, sym/ctx validation, and counters that reconcile#303
wshallwshall wants to merge 6 commits into
mainfrom
asvs-verifier

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Three commits on scripts/asvs/scorecard.py and tests/test_asvs_scorecard.py, and nothing else.
Each is independently landable; they are stacked only because they share one file and one editor.

commit what
V1 ef3ad91e derived anchor form, and the summary stops overstating what a run proves
V2 725347dc --status and a provenance line, so no answer can be quoted without its ref
V3 4667e945 --prove-absences prints the population it scanned
V4 0876a4c1 sym + ctx validation -- a displacement signal, additive to drift

All three share one theme: a number printed without the population or the ref it came from is not a
measurement, and this record has been shipping several of those.


V1 — what does an anchor actually resolve INTO?

An evidence anchor now carries a derived form: code, doc, or foreign. Computed at check time
from where the token lands, never authored, and wired into nothing — not check_completeness, not
any verdict, not the exit code.

Measured against vault origin/main (1a59e4a1) at engine tree c383eeab:

anchors 1,980
located 1,979
GONE 1
code 1,479
doc (173 docstring + 60 comment) 233
foreign (197 .md, 17 .ts, 16 .js, 13 .yml, 8 .toml, 16 scattered) 267
prose or non-Python, of 1,979 located 500 (25.3%)
evidenced cells with NO code anchor at all 17 of 343

A quarter of the record's anchors resolve into prose or a non-Python file that no structural or
executable scheme reaches, ever, and the record presented all of them as code evidence.

The 17-cell figure was derived here independently and matches the 2026-08-09 recut's list exactly —
two methods, same 17 cells, and the recut's number was never used as a target. That is why doc is
stated as a label rather than hedged.

Why docstring position and not a token mask. A docstring is the first statement of a module, class
or function via ast; comments come from tokenize, so a # inside a string literal is not mistaken
for one. A mask misfiles the CSP fragments in messagefoundry_webconsole/_security.py and the whole
SQL Server and Postgres DDL as prose — long, quoted, operator-free strings that read as English while
being the literal subject of the control their cell cites. Position cannot make that mistake, and not
because it is a better mask: it never asks the question.

START, not overlap. The two rules disagree on 57 of the 1,712 located Python anchors, and every one
of the 57 is a code statement whose recorded token runs past the statement into a trailing comment.
The reason is in the code so it does not get "fixed" back.

The summary line already said "resolved" rather than "verified" as of 8cd0d168, which landed
before this branch started. Rather than re-doing a done fix, it is now pinned end-to-end through
main by a test so it cannot silently revert, with the split beside it — "resolved 1,980" still reads
as 1,980 pieces of code evidence.


V2 — query mode, step one: the provenance line and --status

Per ASVS-QUERY-MODE-SPEC-2026-08-09. Provenance plus --status only: no --cell, no --cells-for,
no --cells-since.

The defect, demonstrated live. Two files on this machine, both named asvs-scorecard.toml, both
real, measured 2026-08-09:

partial fail needs-review anchors paths
vault working tree (31d4cd98) 105 3 4 1,978 223
vault origin/main (1a59e4a1) 106 2 4 1,980 224

Nothing in either reading says which is which. That is exactly how three wrong-base errors happened in
one working thread on 2026-08-08/09 — the number was right and the ref was unnamed. With the header,
the first reads BEHIND 38 upstream=origin/main remote-knowledge=36m and the second reads NO-GIT,
because it is an extracted copy rather than a checkout and therefore not quotable at all.

# asvs-status scorecard=31d4cd98 engine=2e59dc6e+dirty
#   scorecard: freshness=BEHIND 38 upstream=origin/main remote-knowledge=36m
#   engine:    freshness=CURRENT upstream=origin/asvs-verifier remote-knowledge=3m
#   generated=2026-08-09T09:40:28+00:00
cells 345: 168 pass, 105 partial, 3 fail, 65 na, 4 needs-review, 0 unverified
examined 345 of 345 (100.0%) against the pinned text; 0 decided with no last_verified; 2 closed by owner decision
evidence 1978 anchors in 343 cells over 223 paths; 0 decided cells carry neither an anchor nor an absence claim
absence 276 claims, 0 of them carrying an observable (the rest cannot be proved by execution)
NOT CHECKED here: whether any anchor still resolves, whether any absence claim is still true, and
completeness against the corpus. --status is a pure read of the scorecard; run verify for those
  • freshness is always populated: CURRENT / BEHIND n / AHEAD n / DIVERGED / NO-UPSTREAM
    / NO-GIT / UNRESOLVED. The last two are extensions that exist because of the never-silent
    rule: calling a non-repo NO-UPSTREAM implies a repo exists, and rendering a comparison git refused
    as CURRENT is the precise silence the field is for.
  • Zero network, zero mutation. git rev-list --left-right --count HEAD...<upstream> counts against
    the last-fetched remote-tracking ref; FETCH_HEAD mtime is probed in both the per-worktree and
    common git dirs and the newest is reported.
  • No --fetch. Permitted by the spec, not required. It is the only mutating path in a tool whose
    whole thesis is that a query must not mutate, and it cannot be tested without network. Say the word
    and it lands with its own control.
  • --status costs 0.69s against the real record, versus 41s for verify. No corpus, no engine tree,
    nothing cached to disk — a cached result would be document 69 in a corpus where 68 assert a tally
    and about one is correct.
  • Exit 0 on a read, 2 when the scorecard will not load, never 1. A query that borrows the gate's
    failure code gets wired into CI as a gate.

One deliberate deviation from the spec's literal shape, documented beside the code: the
freshness / remote-knowledge group is emitted per repo and labelled. The scorecard and the
engine are separate checkouts, and a single field covering both would itself be an unnamed qualifier —
the reader could not tell which repo it described, which is the exact defect the field exists to
prevent. No mandated field is renamed, dropped, or given a value outside its stated set. upstream=
is likewise additive: BEHIND 37 is not a claim until you know behind what.


V3 — --prove-absences prints the population it scanned

Reported by Stream A, routed through the coordinator. Confirmed, and one worse than the report
said
: _prove_one has eight outcomes, three counted and five — not four — that raise a problem
and increment nothing. The escaping-mutation_path branch was the one missed. The proposed fix is
unaffected, since saw N supplies the denominator and the remainder is derivable whatever the branch
count is.

Against the real record:

prove-absences: saw 276 absence claim(s); proved 0 by mutation; 0 static-screened; 276 skipped; 0 problem(s)

All 276 are skipped. Not one absence claim in the record is proven by execution, which is what the
capability exists for. The line now says that instead of implying it.

saw N absence claim(s); is exactly the insertion Stream A verified their parser in
scripts/asvs/prove_report.py tolerates. Nothing else on the line moved and no second line was added,
because their tolerance was verified against that one insertion. The closing arithmetic is documented
on the field:

checked_absences - proved_absences - static_screened - skipped_absences
    == claims that ended in a problem-only branch

len(problems) is deliberately not asserted equal to that remainder, and the test says why: a
SUSPECT finding rides along with a claim already counted in static_screened, so problems and claims
are different populations. Pinning that equality would pin a falsehood.


V4 -- sym + ctx, validated

An anchor may carry sym (enclosing symbol, dotted) and ctx (block chain, Try.body). Derived with
ast, validated here; backfill is Stream D's.

The sentence that ships with it, in the docstring beside the check: this is a DISPLACEMENT signal,
not a defect detector; its security-relevant precision measured 0 of 1 on the only datum the corpus
offers, because 10.5.4's red was a HARDENING. That is why a mismatch is an advisory -- a gate redding
on 10.5.4 would have demanded the rollback of a security improvement.

Additive, measured. Over the 1,712 Python anchors that locate and parse at engine 4667e945,
536 (31.3%) sit in a (sym, ctx) region wider than the 81 lines the retired window covered, so
sym/ctx alone is the looser signal for those. Region spans: median 33, p90 567, max 9,799. A test pins
the additivity directly: three anchors, three outcomes -- drift only, sym/ctx only, both.

The brief's 38.4% / 639 is reproducible under a looser definition -- scoring the region as the
enclosing symbol only, ignoring the ctx refinement, gives 634 at this ref. The figure was not
wrong; it answered a slightly different question. Both support the same conclusion.

ctx, not indentation. A test pins 12.3.5's shape: a re-indent under the same block leaves
(sym, ctx) identical. Indentation flags it and costs a triage; ctx correctly does not fire.

Severity split: malformed is fatal (a chain the deriver cannot produce, or sym/ctx on a non-Python
file -- defects in the record that no engine movement can cause), mismatch is advisory, absent is
silent
. Absent is None, never "": "" is the claim "module level, unnested", so defaulting would
turn all 1,980 un-backfilled anchors into that claim overnight.

Coverage is printed -- sym/ctx asserted on 0 of 1980 anchor(s); the rest assert neither, and absence of the field is NOT agreement.

Proved by making it fail

35 defects injected, one at a time, each confirmed to have landed before the run, each caught by a
named test, with the tree returning green after every one.
No injection was believed on inspection.

set injections all caught
V1 form + summary 9 yes
V2 provenance + --status 12 yes
V3 prove-absences counters 3 yes
V4 sym + ctx 11 yes, after a design change

One injection did NOT fail, and it was worth more than the nine that passed. Deleting
ExceptHandler from the transparent set changed nothing observable, because a single table was
answering two questions -- "can the walk descend into this node?" and "does it contribute a chain
element?" -- so removing the name silently stopped descent instead of starting recording, and the
chain came out identical by a different route. Untestable by construction. Split into _DESCEND_ONLY
and _TRANSPARENT, with a test that they do not drift apart; both halves are now independently
drivable and both injections are caught.

The others that carry the most weight:

  • V2's no-mutation control checks the outcome, not only the intent. Every git argv is captured and
    checked against a read-only allowlist, and the remote-tracking refs and FETCH_HEAD mtime are
    compared across the run. Injecting a fetch reddens both halves independently. Asserting on argv
    alone would pass a tool that reached the network some other way.
  • V1's injection 9 demotes doc anchors to a problems entry. If someone later teaches this gate
    that a doc-only cell is unevidenced, that test goes red and says why.

Negative controls throughout: a file with no prose yields zero doc; an empty form run prints
0 of 0 (0.0%) rather than dividing by zero; an empty prove run says saw 0; a scorecard with every
anchor broken still exits 0 under --status, because --status does not check anchors.

The V2 tests use real git repositories — a bare origin and a clone — not a stub, because the
failure being prevented is a fact about remote-tracking refs, fetch recency and worktree layout, and a
stub would reproduce my assumptions instead of git's behaviour. None is marked skipif, and one test
asserts git is present so the suite cannot silently evaporate.

Checks

ruff format --check, ruff check, mypy strict (-p scripts.asvs and the test file), and 120
scorecard tests
are clean. CI's mypy legs run messagefoundry messagefoundry_webconsole and do not
cover scripts/, so the type check was run explicitly.

Cost: verify is 41.1s against a 40.2s pre-change baseline on the same tree — the form classification is
free, and check_anchors now caches the per-file read and parse (1,980 anchors over ~224 files).

Not in scope, and noticed in passing

  • The single GONE anchor is 15.3.1 at messagefoundry/config/wiring.py, killed by e7566082.
    Adjudicated by the coordinator as class (c) — the gap the anchor certified was CLOSED — and routed to
    the assessment session. Reported here, never re-anchored, and no target suggested.
  • render_current is deliberately untouched. It generates the vault's ASVS-CURRENT.md, which is
    diffed against a committed copy, so adding the form split there would red that drift gate until the
    vault re-renders.
  • docs/BACKLOG.md:180 and :199 cite scripts/asvs/scorecard.py:387,409-413 and :395. Already
    stale; these commits move them further. Residual-lint work, not this.

Generated with Claude Code.

…ry overstating what a run proves

Measured 2026-08-09, vault origin/main (1a59e4a1) scorecard against engine tree c383eea -- quoted as
a (file x ref) pair, because a count is a fact about one and this programme has already produced
three wrong-base errors by dropping the qualifier:

  1,980 anchors, 1,979 located, 1 GONE (15.3.1, from the JWS signing-key redaction; not this stream)
  form split: 1,479 code / 233 doc (173 docstring + 60 comment) / 267 foreign / 0 undetermined
  foreign by extension: 197 .md, 17 .ts, 16 .js, 13 .yml, 8 .toml, 16 scattered
  500 of 1,979 (25.3%) resolve into prose or a non-Python file, which no structural or executable
  scheme reaches, ever -- and the record presented all 1,980 as code evidence
  17 of 343 evidenced cells carry NO code anchor at all, derived here independently and agreeing
  exactly with the 2026-08-09 recut's figure

WHAT CHANGED

`form` is DERIVED at check time, never authored: `foreign` for a non-Python file, `doc` for a match
landing inside a docstring or a `#` comment, `code` otherwise, and None -- reported as undetermined,
never folded into `code` -- for Python that will not parse.

Classified by DOCSTRING POSITION, not by a token mask. A docstring is the first statement of a
module, class or function via `ast`; comments come from `tokenize`, so a `#` inside a string literal
is not mistaken for one. A mask misfiles the Content-Security-Policy fragments in
messagefoundry_webconsole/_security.py and the whole SQL Server and Postgres DDL as prose -- long,
quoted, operator-free strings that read as English while being the literal subject of the control
their cell cites. Position cannot make that mistake, and not because it is a better mask: it never
asks the question.

The token's START decides, not any overlap with its end. Measured, those two rules disagree on 57 of
1,712 located Python anchors, and every one of the 57 is a code statement whose recorded token runs
past the end of the statement into a trailing comment.

`doc` is a LABEL and never a demotion. It feeds no verdict, no completeness check and no exit code,
and a test pins that: a cell evidenced only by prose stays green and stays complete. 17 cells rest
genuinely on documentation, which for a documentation requirement is the correct ground.

Only anchors that LOCATE are classified. A GONE or AMBIGUOUS token has no landing site, so giving it
a form would be an invented number inside the one figure whose purpose is to stop the record
overstating itself. The summary prints both populations rather than either alone.

The summary line already said "resolved" rather than "verified" (8cd0d16). The split now rides
beside it with its denominator, because "resolved 1,980" still reads as 1,980 pieces of code
evidence.

Also caches the per-file read and parse inside check_anchors: 1,980 anchors concentrate onto ~224
files. End-to-end run against the real record is 41.1s against a 40.2s baseline -- the classification
is free, the cost is check_absences.

PROVED BY MAKING IT FAIL

Nine defects injected one at a time, each confirmed to have landed before the run: any string token
treated as a docstring; undetermined defaulting to code; a `#` scan replacing tokenize; left-overlap
replacing start; the form counted before the occurrence guards; the summary saying "verified" again;
the split not printed; the split printed without its denominator; and doc anchors demoted to a
problem. All nine were caught by a named test, and the tree returned green after each. Two negative
controls: a file with no prose yields zero doc, and an empty run prints 0 of 0 (0.0%) rather than
dividing by zero.

ruff format --check, ruff check, mypy strict (-p scripts.asvs and the test file) and the 74 scorecard
tests are clean.
BRIEF.md is the work order for this worktree. It rode along in the branch and would
have landed on main. Kept on disk for the worker, excluded from the index.
…uoted without its ref

Query mode step one, per ASVS-QUERY-MODE-SPEC-2026-08-09. Provenance line plus --status only: no
--cell, no --cells-for, no --cells-since.

THE DEFECT THIS CLOSES, DEMONSTRATED LIVE

Two files on this machine, both named asvs-scorecard.toml, both real, measured 2026-08-09:

  vault working tree (31d4cd98)  105 partial, 3 fail, 4 needs-review, 1,978 anchors, 223 paths
  vault origin/main  (1a59e4a1)  106 partial, 2 fail, 4 needs-review, 1,980 anchors, 224 paths

Nothing in either reading says which is which. That is exactly how three wrong-base errors happened
in one working thread on 2026-08-08/09 -- the number was right and the ref was unnamed. With the
header the first reads BEHIND 38 upstream=origin/main remote-knowledge=36m and the second reads
NO-GIT, because it is an extracted copy rather than a checkout and therefore not quotable at all.

The spec's own measured case on that same vault checkout was BEHIND 37 at 23 minutes.

WHAT LANDED

  # asvs-status scorecard=31d4cd98 engine=2e59dc6e+dirty
  #   scorecard: freshness=BEHIND 38 upstream=origin/main remote-knowledge=36m
  #   engine:    freshness=CURRENT upstream=origin/asvs-verifier remote-knowledge=3m
  #   generated=2026-08-09T09:40:28+00:00

freshness is one of CURRENT / BEHIND n / AHEAD n / DIVERGED / NO-UPSTREAM / NO-GIT / UNRESOLVED, and
it is ALWAYS populated. NO-GIT and UNRESOLVED are extensions that exist because of the never-silent
rule, not in spite of it: calling a non-repo NO-UPSTREAM implies a repo exists, and rendering a
comparison git refused as CURRENT is the precise silence the field is for.

ONE DELIBERATE DEVIATION from the spec's literal shape, documented beside the code so it is not
"fixed" back: the freshness and remote-knowledge group is emitted PER REPO and labelled, because the
scorecard and the engine are separate checkouts and a single field covering both would itself be an
unnamed qualifier -- the reader could not tell which repo it described. No mandated field is renamed,
dropped, or given a value outside its stated set. upstream= is likewise additive: BEHIND 37 is not a
claim until you know behind what, and on a feature branch the branch's own upstream and the canonical
line are different questions.

ZERO NETWORK, ZERO MUTATION. Staleness comes from git rev-list --left-right --count HEAD...<upstream>,
which counts against the last-fetched remote-tracking ref, and from the mtime of FETCH_HEAD, probed in
both the per-worktree and common git dirs and reported as the newest. There is deliberately NO
--fetch: it is permitted by the spec but not required, it is the only mutating path in a tool whose
whole thesis is that a query must not mutate, and it cannot be tested without network. Refreshing is
git fetch, by hand, on purpose. Say so if you want it and it will be added with its own control.

--status is a pure read of the scorecard: no corpus, no engine tree, nothing cached to disk (a cached
result would be document 69 in a corpus where 68 assert a tally and about one is correct). It runs in
0.69s against the real record, against 41s for verify. It reports all six verdicts including
needs-review, which the verify summary line omits, and it NAMES WHAT IT DID NOT CHECK -- anchor
resolution, absence truth and corpus completeness are verify's job, and a structural tally printed
under a heading implying anchor health would be the same overstatement corrected in the last commit.

Exit 0 on a read, 2 when the scorecard will not load, NEVER 1. A query that borrows the gate's failure
code gets wired into CI as a gate.

PROVED BY MAKING IT FAIL

Twelve defects injected one at a time, each confirmed landed, each caught by a named test, tree green
after every one: the tool fetches; dirty never reported; freshness omitted on no-upstream; a non-repo
mislabelled NO-UPSTREAM; DIVERGED collapsed into BEHIND; remote-knowledge a constant instead of an
age; a never-fetched repo reporting an age; one freshness field for two repos; --status no longer
naming what it did not check; --status returning the gate's exit code; provenance suppressed on the
error path; needs-review dropped from the counts.

The no-mutation test is the negative control and it checks the OUTCOME, not only the intent: every
git argv is captured and checked against a read-only allowlist, AND the remote-tracking refs and
FETCH_HEAD mtime are compared across the run. Injecting a fetch reddens both halves independently.

Tests use real git repositories -- a bare origin and a clone -- rather than a stub, because the
failure being prevented is a fact about remote-tracking refs, fetch recency and worktree layout, and
a stub would reproduce my assumptions instead of git's behaviour. None is marked skipif, and one test
asserts git is present so the suite cannot silently evaporate.

ruff format --check, ruff check, mypy strict (-p scripts.asvs and the test file) and 93 scorecard
tests are clean.
… counters can be reconciled

Reported by Stream A and routed through the coordinator. Confirmed, and it is one worse than the
report said.

WHAT I MEASURED

`_prove_one` has EIGHT outcomes. Three increment a counter; FIVE raise a problem and increment
nothing:

  counted    no mutation_path                     -> skipped_absences
  counted    mutated exit 1                       -> proved_absences
  counted    static backstop (may ALSO raise SUSPECT) -> static_screened
  uncounted  mutation_path escapes the tree       -> PROVE-ERROR
  uncounted  mutation_path is not a file          -> PROVE-ERROR
  uncounted  baseline observable not green        -> PROVE-ERROR
  uncounted  mutated exit 0                       -> UNPROVEN
  uncounted  mutated exit not in {0,1}            -> PROVE-ERROR

The report said four uncounted; it is five -- the escaping-`mutation_path` branch was missed. The
proposed fix is unaffected, because `saw N` supplies the denominator and the remainder is then
derivable whatever the branch count is. Reporting it rather than quietly correcting it: a count is
a fact, and this one was off by one in the direction that flatters the tool.

Against the real record, vault origin/main 1a59e4a1:

  prove-absences: saw 276 absence claim(s); proved 0 by mutation; 0 static-screened; 276 skipped;
  0 problem(s)

All 276 are SKIPPED. Not one absence claim in the record is proven by execution, which is what the
capability exists for -- the line now says that instead of implying it.

WHAT CHANGED

`saw N absence claim(s);` leads the summary, exactly the insertion Stream A verified their parser in
scripts/asvs/prove_report.py tolerates. Nothing else on the line moved, and no second line was added,
because their tolerance was verified against that one insertion and not against a new line.

`checked_absences` is now set by BOTH passes and is incremented BEFORE any outcome branch, so it is
right whichever branch a claim takes. The closing arithmetic is documented on the field:

  checked_absences - proved_absences - static_screened - skipped_absences
      == claims that ended in a problem-only branch

`len(problems)` is deliberately NOT asserted equal to that remainder, and the test says why: a
SUSPECT finding rides along with a claim already counted in static_screened, so problems and claims
are different populations. Pinning that equality would pin a falsehood.

PROVED BY MAKING IT FAIL

Three injections, each confirmed landed, each caught: the saw-N term deleted; the population counted
only for claims that get past the first guard; skipped claims dropped from the arithmetic. A negative
control asserts an empty run says `saw 0` rather than printing four zeroes that read like a clean
pass over a real population.

ruff, mypy strict and 97 scorecard tests clean.
@wshallwshall wshallwshall changed the title asvs(verifier): name what an anchor resolves INTO, and stop the summary overstating what a run proves asvs(verifier): anchor form, a ref-stamped --status, and counters that reconcile against what was scanned Aug 9, 2026
…l, additive to drift

An anchor may now carry `sym` (enclosing symbol, dotted) and `ctx` (block-node chain, e.g. Try.body).
Both are derived with `ast` and VALIDATED here; backfilling them is Stream D's.

WHAT IT IS, AND THE SENTENCE THAT HAD TO SHIP WITH IT

This is a DISPLACEMENT signal, not a defect detector; its security-relevant precision measured 0 of 1
on the only datum the corpus offers, because 10.5.4's red was a HARDENING. It sits in the docstring
beside the check, because a signal described as a defect detector where people read it will be quoted
as one -- and this one would then be quoted at 0% precision. Read it as "your reasoning about this
cell is stale, go re-read it".

That is why a MISMATCH is an ADVISORY. A gate that redded on 10.5.4 would have demanded the rollback
of a security improvement.

WHAT I MEASURED, AND WHERE THE BRIEF'S FIGURE CAME FROM

Vault origin/main 1a59e4a1's anchors against engine tree 4667e94, over the 1,712 Python anchors that
locate and parse. The region is the innermost node the (sym, ctx) PAIR pins -- both fields must match,
so the pair is only as loose as its tighter half:

  536 of 1,712 (31.3%) sit in a region SPANNING MORE than the 81 lines the retired +/-40 window
      covered. For those, sym/ctx alone is the LOOSER signal.
  region spans: median 33, p90 567, max 9,799
  1,403 of 1,712 unnested (ctx == ""); 280 at module level (sym == "")
  innermost region kind: 1,008 scope, 328 If, 280 module, 50 Try, 21 For, 10 AsyncWith, 8 handler

The brief's 38.4% / 639 IS REPRODUCIBLE, under a looser region definition: scoring the region as the
enclosing SYMBOL only and ignoring the ctx refinement gives 634 at this ref against the brief's 639.
So the figure was not wrong, it answered a slightly different question. Both definitions support the
same conclusion, which is the only one that matters here: replacing drift with sym/ctx would lose
detection on roughly a third of the record, so this is ADDITIVE and a test pins it -- three anchors,
three outcomes (drift only, sym/ctx only, both).

PROCESS NOTE, recorded because it is the failure this programme is about: the first draft of that
docstring carried "39.7% of 1,467 ... 583 anchors". Those were placeholders I wrote before running
anything. They were replaced with measured values before commit, but they existed, and they read
exactly like measurements.

ctx, NOT INDENTATION

Cell 12.3.5 carries the identical 4-versus-8 indent mismatch as 10.5.4 and is a non-event -- a
hand-trimming slip, ctx unchanged at both ends. A test pins that a re-indent under the same block
leaves (sym, ctx) identical. Indentation flags it and costs a triage; ctx correctly does not fire.

SEVERITY SPLIT

  MALFORMED (fatal)  -- a ctx naming a node type or field the deriver cannot produce, a sym that is
                        not a dotted identifier, or sym/ctx on a non-Python file. These are defects in
                        the RECORD; no engine movement can cause one, and an unmatchable chain would
                        otherwise advise forever. Validated against _BLOCK_FIELDS, the same table the
                        deriver walks, so an accepted chain is one the deriver can actually produce.
  MISMATCHED (advisory) -- displacement. See above.
  ABSENT (silent)    -- `None`, not "". The loader keeps them distinct on purpose: "" is the claim
                        "module level / unnested", so defaulting absent to "" would turn all 1,980
                        un-backfilled anchors into that claim overnight.

Coverage is printed -- "sym/ctx asserted on 0 of 1980 anchor(s); the rest assert neither, and absence
of the field is NOT agreement" -- because a structural check reaching 0 of 1,980 while printing like a
whole-corpus result is the overstatement this branch keeps correcting.

AN INJECTION THAT DID NOT FAIL, AND THE DESIGN CHANGE IT FORCED

Eleven defects were injected. The first pass ran ten and ONE WAS NOT CAUGHT: deleting `ExceptHandler`
from the transparent set changed nothing observable. Cause: one table was answering two questions --
"can the walk descend into this node?" and "does it contribute a chain element?" -- so removing the
name silently stopped DESCENT instead of starting RECORDING, and the chain came out identical by a
different route. Untestable by construction.

Split into `_DESCEND_ONLY` (descent) and `_TRANSPARENT` (recording), with a test asserting they do not
drift apart. Both halves are now independently drivable, and both injections are caught: L5a reddens
the recording test, L5b the descent test. The failed injection was worth more than the nine that
passed.

Re-run: 11 of 11 caught, each confirmed landed, tree green after every one.

NOT TOUCHED: `_prove_one` and `_run_prove_absences` -- Stream A owns those for A2. Verified: zero
lines of this diff touch either.

ruff format --check, ruff check, mypy strict (-p scripts.asvs and the test file) and 120 scorecard
tests are clean.
@wshallwshall wshallwshall changed the title asvs(verifier): anchor form, a ref-stamped --status, and counters that reconcile against what was scanned asvs(verifier): anchor form, a ref-stamped --status, sym/ctx validation, and counters that reconcile Aug 9, 2026
@wshallwshall
wshallwshall marked this pull request as ready for review August 9, 2026 14:01
@wshallwshall
wshallwshall enabled auto-merge (squash) August 9, 2026 14:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant