test: attribute a refusal by SQLSTATE, and fix the tool that grades it (#432) - #1036
Conversation
#432) Two changes, and the second is why the first could be measured. THE PORT. projection_privilege.sh decided four things by matching error text, and two of them were load-bearing. Its own comment says why: both ACL layers raise 42501, so a bare "refused" stays true if the SQL REVOKE is deleted and the C check catches it instead -- measured there, the suite still passed 14/14 with the REVOKE removed. What separates the layers is the fixture, not the wording. Called with a projection name that does not exist, on a table the role may read: stopped by the SQL grant 42501 the body never ran to notice the name past the SQL grant 42704 it ran, and the projection lookup raised So the refusal is attributed by what the code REACHED, which no rephrasing can move. RLS is a third code again: 0A000, a different SQLSTATE class from either ACL refusal. Both harnesses do this now; the shell half reads the state through psql -v VERBOSITY=sqlstate. That also gave two orderings the source asserts and neither harness tested: the base ACL is checked before the projection is looked up, so a caller with no SELECT cannot learn whether a projection exists on a table it may not read; and the ACL is checked before RLS, so a caller with no privilege is not told the table has row-level security enabled. The second is a correction src/columnar_vacuum.c records being made in review. Five mutations, each asserted to apply at both call sites before the run. Moving the ACL below the projection lookup but ABOVE its raise reddens nothing, correctly -- 42501 still wins, which is what says the ordering arms measure an ordering rather than the presence of a check. Moving it below the raise reddens both, and the shell half prints the disclosure: got [42704] want [42501]. THE TOOL. compare_to_bash.py decides whether a port is one-for-one, which is this issue's definition of done, and it was reading the wrong argument: expect.num(got, 1, NAME) -> NAME correct, so it looked right expect.sqlstate(err, "42501", NAME) -> "42501" expect.text(got, "none", NAME) -> "none" `[^)]*?` is lazy and stopped at the first quoted argument. Every SQLSTATE assertion was read as the literal 42501, counted as an "extra" the bash suite lacks, and the real property reported MISSING. This issue's ports are exactly the ones replacing a grep with a SQLSTATE assertion, so the tool went blind in proportion to the work being done well. It parses with ast now: the last string argument, with f-strings as templates, both arms of a conditional, and the `name` column of a parametrize. Bash interpolations reduce the same way, including $1 -- the commonest one in a check name, which the first version of the reducer missed because its pattern required a letter after the dollar. Measured over every pair in the tree: 61 bash properties reported missing, now 0. Of the 61, 34 were never missing. The rest were real and are closed here: stats_privilege had invented a name for a property the bash suite already named, and zonemap_boundaries was missing its `backend alive` premise outright. Neither was visible while the tool reported the wrong string. Verified: guard half 295 (729 checks, 0 unrun), cluster half 217 (614 checks, 0 unrun), projection_privilege.sh 24/24, zonemap_boundaries.sh 9/9, docs_style.sh PASSED, and all seven pairs grade rc=0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
#1022 landed while this was open. Two conflicts: - test/pytest/TESTS.md: main's test_docs_stripe_floor.py took 34, so this branch's two sections become 35 and 36. #1028's numbering rule reports toc=36 sec=36, no gap, no inversion, no dangling anchor. - test/pytest/expected_tests.txt: re-derived, not resolved. Both sides had a guard number measured against a tree holding only its own arms. Collection on the merged tree says 298 and 217. Verified: guard half green at 298 (737 checks, 0 unrun). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
OffgridwithJD
left a comment
There was a problem hiding this comment.
APPROVE at ebcfabf8. 14 checks, 0 failures, CLEAN. The diagnosis is right, the fix works,
and I verified it on a real pair rather than from the diff. One residual below that I would have
expected in scope, and it is not a false red, so it does not block.
The diagnosis is the best line in the PR
The tool got blinder as the work it grades got better.
That is exactly it, and it explains why the note I left on #1027 (PORT IS INCOMPLETE for a
complete port) read as a tool limitation rather than a bug. [^)]*?"([^"]+)" being lazy means
the first quoted argument wins, so expect.sqlstate(err, "42501", NAME) reported 42501 as the
name — and every #432 port exists to replace a message grep with a SQLSTATE. The tool was
worst precisely where the work was best.
Verified on the real pair
compare_to_bash.py hilbert_locality.sh test_hilbert_locality.py
literal matches: 19 | template matches: 11 | missing: 0
VERDICT: every bash property is covered rc=0
Template matching earns its keep: 11 of the 30 matches are names both harnesses build at
runtime, including the box $box: family. Reporting them separately is the right call — a
template match says the two assert a property of the same shape, not over the same values, and
folding them in would have overstated parity.
guard_tests 298, matching `298 tests collected` by derivation
cluster_tests 217, matching `217 tests collected`
guard half 298 passed / 737 checks, --pgc-expect-tests 298 armed
TESTS.md TOC 36 entries 1..36, sections 36 1..36, both 0 out-of-order
The port asserts the SQLSTATE 10 times — 6x 42501, plus 0A000 and 42704 — so it is
attributing the refusal rather than matching its sentence, which is the point of the change.
The residual: three shapes where the name is NOT the last argument
node.args[-1] is right for num, text, sqlstate, rows, at_least and the name=
kwarg. It is wrong for three, and two of them are visible in the output above:
| call | what the tool takes | the actual name |
|---|---|---|
refusal(result, name, *patterns) |
the last pattern | name |
cannot_run(reason, detail) |
detail |
reason — _record(name=reason, ...) |
plan_marker(plan, key, name=...) |
the key and the name | the name |
Measured on a probe through the new _py_names, 7 of 10 shapes correct:
expect.refusal(result, "refusal: THE NAME", "pattern one", "pattern two")
-> 'pattern two'
expect.cannot_run("MISSING_DEPENDENCY", "cannot_run: this is the DETAIL not the name")
-> 'cannot_run: this is the DETAIL not the name'
expect.plan_marker(plan, "a key with no name")
-> 'a key with no name'
Two of the three extra lines on hilbert_locality are this, not real extras:
extra Columnar Projected Columns <- a plan_marker KEY (line 719)
extra the two partitions are not different ({}), ... <- a cannot_run DETAIL (line 792)
extra parallelism is off on the connection ... <- genuinely extra, and the
docstring says so: "No bash twin
by name"
Why it does not block. rc is driven by MISSING, so extras move neither the exit status nor
the verdict. Nothing is graded wrong today, and no ported file uses refusal at all — it
appears only in test_raises_sqlstate.py and test_guards_pinned.py, which have no bash twin.
cannot_run and plan_marker do appear in a graded pair, which is why the noise is visible.
Why I would still finish it here. The PR's argument is that false entries are what made the
tool untrustworthy, and two false entries survive in its own output on a pair it adds to the
standing list. The next person reading that report has to know which of three extra lines to
believe.
And the tests pin the rule without its counter-examples. Eight arms, and the first is
test_the_name_is_the_last_argument_not_the_first_string. None exercises refusal,
cannot_run or plan_marker. test_the_ported_suites_in_this_tree_are_graded_one_for_one is
the right idea and cannot catch this either, because it asserts rc == 0 and extras never move
rc.
A smaller fix than it looks. Skipping args[-1] when a name= kwarg is present closes
plan_marker on its own and is one condition. The other two need the position named per helper.
Deriving it from inspect.signature(Expect.<helper>) would be self-maintaining, but it means
importing pgc_vacuity, which pulls in pytest — and the tool is deliberately standalone today
(ast, re, sys). A small map with a comment pointing at the signatures may be the cheaper
honest choice; your tool, your call.
One note for the merge
#1035 also moves expected_tests.txt, so whichever of us lands second re-derives. Your own
guard_tests 280 finding applies to exactly this: both numbers will be right for the tree they
were taken on and wrong for the merge.
, #1036) Both found by @OffgridwithJD reviewing the first commit, and both re-measured here rather than taken on the review's word. 1. A PUBLISHED SENTENCE WAS FALSE. CHANGELOG.md and TESTS.md said UNMET_PRECONDITION was a TRUE extra "which the bash suite has no check for". The bash suite HAS four checks for it, at hilbert_locality.sh:574 and the three after it: check_unrunnable "box $box: groups read, Z-order" UNMET_PRECONDITION ... The tool cannot see them. Its bash extractor reads `check(_num|_ratio|_text|_timing)?`, and `check_unrunnable` matches no branch. Widening that regex by that one alternative and changing nothing else takes hilbert_locality from rc=0 missing=0 to rc=1 missing=2 -- "box $box: groups read, Hilbert" and "box $box: groups read, Z-order" -- every other pair unchanged. So the port emits ONE record where bash emits four per box, and two have no counterpart in the port. Eight bash check helpers are invisible to that regex (89 invocations across test/*.sh, counted as invocations with definitions excluded). Filed separately: widening it reddens a pair and is a port's worth of work, not a tool fix. The defensible sentence is the narrow one, and both files now say it. 2. A SECOND COINCIDENCE, INSIDE THE CLAUSE THAT FIXED THE FIRST. `-1` is a claim about the CALL SITE; the drift guard reads the SIGNATURE. They agree only while no optional parameter sits after the name, because an optional one can still be passed positionally: expect.rows(got, want, "THE NAME", "the reason") -> read 'the reason' expect.plan_marker(plan, "key", "THE NAME") -> read nothing at all Both legal, both read wrong, every guard green. The second is worse: a DROPPED name reports the bash property MISSING, and MISSING drives rc. Latent rather than live -- no call site in the tree passes a trailing optional positionally, verified over all 1025 -- but #1037 makes allow_empty a reason STRING, which is exactly that argument. Closed in the SIGNATURES rather than patched in the reader: rows, row_set, plan_marker and plan_node take everything after the name as keyword-only, so a wrong call is a TypeError instead of a misread name: Expect.rows() takes 4 positional arguments but 5 were given No call site changed; all four already used keywords, verified over all 1025 before the change. Removal proof, each mutation asserted to apply by md5 and the tree restored: revert row_set's * names row_set revert rows' * names rows revert plan_marker's * names plan_marker revert plan_node's * names plan_node Guard job green at 304 collected, re-derived by collection: 304 passed, 756 checks, 756 pass + 0 fail + 0 unrun. docs_style.sh 14 checks PASSED. Counts are now labelled with the tree they were taken on, because they move against their own branch head. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
A removal-proof row said "a wrong entry for a helper no arm covers (at_least)" and then named the two arms that redden. The claim meant no BEHAVIOURAL arm, which is the whole reason that mutation exists, and the row now says so. Re-measured rather than reasoned: with `"at_least": 0` added to the table, the drift guard and the whole-tree arm fail and the four behavioural arms stay green -- 2 failed, 12 passed. Found by auditing my own diff for absence-shaped claims, prompted by @OffgridwithJD correcting one of their own on #1039. Their rule is the reusable part and it is better than the correction: **"nothing checks this" is a MEASUREMENT claim that costs one grep, and it is most tempting exactly when the finding beside it is solid.** A positive result feels complete and an unmeasured sentence rides in behind it. The other three absence claims in this branch's diff were checked and stand: extras never move `rc` (measured across all seven pairs), and the tool cannot see `check_unrunnable` (measured by widening the regex). docs_style.sh 14 checks PASSED. Guard job 310 collected, 774 checks, 774 pass + 0 fail + 0 unrun. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
Two of four check_unrunnable calls in one loop in hilbert_locality.sh
carried a shortened name while the other two matched their runnable twins
exactly:
:574 check_unrunnable "box $box: groups read, Z-order"
:597 check_num "box $box: groups read over $PLACEMENTS placements, Z-order"
:578 check_unrunnable "box $box: Hilbert reads fewer groups than Z-order"
:602 check_text "box $box: Hilbert reads fewer groups than Z-order"
So the property had TWO ledger keys and which one appeared depended on
whether that box's two partitions came out different that day. The key was
a function of the data. hilbert_locality is not ledger-covered, so this was
a wrong key waiting to be seeded, not a wrong number today.
THE CONVENTION IS ALREADY NEAR-UNIVERSAL, which is what made the lapse
invisible: 23 of 25 check_unrunnable call sites carry the runnable name
(hilbert_cluster 9 of 9, projection_rewrite 11 of 11 counting its pgc_pass
twin). A shorter name in a new refusal branch reads as ordinary, and both
halves of a two-branch site are rarely read together.
AND A GUARD, because a rename in either branch reintroduces it silently.
Selftest 480 drives .github/scripts/unrunnable-arm-names.py over test/*.sh.
THE TOOL DERIVES THREE THINGS A LIST OF THEM GOT WRONG FIRST:
which functions record from the pgc_record call in the body. Two
hand-written lists omitted pgc_pass, and
projection_rewrite then read as a false orphan.
which argument is the pgc_skip records "$2": argument one is the
name CAPABILITY. Reading it takes `arrow` where the
check is called `arrow support is present` --
the bash mirror of the defect #1036 and #1038
closed on the python side, 22 call sites.
pgc_require_tools records a FIXED name.
what is a refusal from the verdict recorded, not the spelling.
check_skip is deliberately NOT swept. A skipped arm has no runnable
counterpart by construction -- the name IS the arm -- so 21 of its 23 call
sites have no twin and always will; sweeping it would report 21 mismatches
that are all correct code.
THE PART CARRIES A POSITIVE CONTROL, because the guard's steady state is
zero mismatches and a broken sweep reports zero too. It drives the real
tool over a fixture whose refusal branch names something the file never
records, and over a control where the names agree.
Three arms pin values that were WRONG in a draft of the tool rather than
restating the code: the refusal set, pgc_skip's argument TWO, and pgc_pass
and pgc_fail being twins.
AND THE CENSUS RECIPE IN check_ledger_budget.txt READ FIELD FOUR AND
RETURNED ZERO. #1010 inserted the majors as field 4, moving the last-red to
field 5. The CHANGELOG entry for #1010 says this file carries the corrected
form; it did not. The gate is unaffected -- it computes the census itself --
so the harm is a reviewer re-deriving 0 against a stated number and
"correcting" a budget that was right. Fixed here rather than filed because
this change moves that very number.
Verified, red then green with the rename as the only variable between the
two runs:
before the rename harness_selftest RC=1, 943 checks, exactly one FAIL,
naming both offending sites; all 8 other arms of the
new part green, including the positive control
after the rename harness_selftest RC=0, 943 checks
sweep 25 sites, 24 compared, 1 dynamic, 0 mismatches
shellcheck -S error over test/*.sh test/selftest/*.sh clean
docs_style 14/14
ledger rows 1197 -> 1206 (+9, the new part), census 1189 ->
1198 re-derived by the corrected command, ceiling
249 -> 249, orphans 0, gate rc=0 against db74d9e
widened extractor all 7 ported pairs rc=0 missing=0, which is the one
red #1040's grader half would otherwise land with
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
Two changes. The second is why the first could be measured, and it turns out to be the larger finding.
The port:
projection_privilege.shgets a pytest twin, and both halves stop reading messagesThe bash suite decided four things by matching error text, and two of them were load-bearing rather than decorative. Its own comment says why:
Both ACL layers raise 42501, so the code alone does not separate them. The fixture does. Called with a projection name that does not exist, on a table the role may read:
42501-- the body never ran to notice the name42704-- it ran, and the projection lookup raisedThe refusal is attributed by what the code REACHED, which is a fact about execution rather than about wording. RLS is a third code again:
0A000, fromERRCODE_FEATURE_NOT_SUPPORTED, a different SQLSTATE class from either ACL refusal. Both harnesses do this now; the shell half reads the state throughpsql -v VERBOSITY=sqlstate.Two orderings the source asserts and neither harness tested
src/columnar_vacuum.crecords this being corrected in review, with the measurement; nothing tested it.Removal proof: five mutations, each asserted to apply at both call sites
got [42704] want [42501]Row four is the one that says the ordering arms measure an ordering rather than the presence of a check. Row five is the disclosure itself, printed.
The tool:
compare_to_bash.pywas reading the wrong argumentIt decides whether a port is one-for-one, which is #432's definition of done. The python side was matched with
expect\.\w+\([^)]*?"([^"]+)"..., and[^)]*?is lazy, so it stopped at the FIRST quoted argument:expect.num(got, 1, NAME)NAME-- correct, which is why it looked rightexpect.sqlstate(err, "42501", NAME)"42501"expect.text(got, "none", NAME)"none"Every SQLSTATE assertion was read as the literal
42501, counted as an "extra" the bash suite lacks, while the real property was reported MISSING. #432's ports are exactly the ones replacing a grep on a message with a SQLSTATE assertion, so the tool went blind in proportion to the work being done well.test_hilbert_locality.pyrecords somebody hitting this and working around it by rewriting their test file until the count fell, then concluding the rest needed a change here. It did.Measured over every pair in the tree
Of the 61, 34 were never missing. The rest were real and are closed here:
stats_privilegehad invented a name for a property the bash suite already named, andzonemap_boundarieswas missing itsbackend alivepremise outright. Neither was visible while the tool reported the wrong string.It parses with
astnow and takes the last string argument, resolving f-strings to templates, both arms of a conditional, and thenamecolumn of a@pytest.mark.parametrize— the idiom a repeated bash property should be ported to. Bash interpolations reduce the same way, including$1, the commonest one in a check name, which my first version of the reducer missed because its pattern required a letter after the dollar.test_compare_to_bash.pyguards it. Four mutations, each reddening its own arm plustest_the_ported_suites_in_this_tree_are_graded_one_for_one, which is the arm that matters: a guard over invented sources proves the extractor reads python, not that the tool grades this tree.Verified
Closes part of #432. The remaining text-only refusal suites are
rls_direct_storageandimport_export_privilege.🤖 Generated with Claude Code
https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw