Skip to content

test: attribute a refusal by SQLSTATE, and fix the tool that grades it (#432) - #1036

Merged
jdatcmd merged 2 commits into
mainfrom
test/432-projection-privilege
Sep 13, 2026
Merged

test: attribute a refusal by SQLSTATE, and fix the tool that grades it (#432)#1036
jdatcmd merged 2 commits into
mainfrom
test/432-projection-privilege

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Two changes. The second is why the first could be measured, and it turns out to be the larger finding.

The port: projection_privilege.sh gets a pytest twin, and both halves stop reading messages

The 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 layers reject this role, so a bare "refused" stays true if the REVOKE is deleted and the C check catches it instead -- measured: with the REVOKE removed this suite still passed 14 of 14.

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:

the caller outcome
no EXECUTE 42501 -- the body never ran to notice the name
EXECUTE 42704 -- it ran, and the projection lookup raised

The 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, from ERRCODE_FEATURE_NOT_SUPPORTED, a different SQLSTATE class from either ACL refusal. Both harnesses do this now; the shell half reads the state through psql -v VERBOSITY=sqlstate.

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 named projection exists on a table it may not read.
  • The ACL is checked before RLS, so a caller with no privilege is not told the table has row-level security enabled. src/columnar_vacuum.c records this being corrected in review, with the measurement; nothing tested it.

Removal proof: five mutations, each asserted to apply at both call sites

mutation pytest shell
drop the base ACL check 4 arms red --
drop the RLS refusal 2 arms red --
RLS before the ACL the RLS-ordering arm alone the RLS-ordering arm alone
ACL below the lookup but above its raise nothing red, correctly same
ACL below the raise both ordering arms both: 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.py was reading the wrong argument

It 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:

call name read
expect.num(got, 1, NAME) NAME -- correct, which is why it looked right
expect.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.py records 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

pair missing before after
differential 6 0
hilbert_locality 13 0
native_ownership 1 0
native_projection 0 0
projection_privilege 23 0
stats_privilege 9 0
zonemap_boundaries 9 0
total 61 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.

It parses with ast now and takes the last string argument, resolving f-strings to templates, both arms of a conditional, and the name column 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.py guards it. Four mutations, each reddening its own arm plus test_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

guard half            295 collected, 729 checks, 0 unrun
cluster half          217 collected, 614 checks, 0 unrun
projection_privilege.sh   24/24 PASSED
zonemap_boundaries.sh      9/9  PASSED
docs_style.sh                   PASSED
all seven pairs           rc=0, 0 missing

Closes part of #432. The remaining text-only refusal suites are rls_direct_storage and import_export_privilege.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw

jdatcmd and others added 2 commits September 12, 2026 21:23
#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 OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jdatcmd
jdatcmd merged commit 73e8e3d into main Sep 13, 2026
14 checks passed
@jdatcmd
jdatcmd deleted the test/432-projection-privilege branch September 13, 2026 14:04
jdatcmd added a commit that referenced this pull request Sep 13, 2026
, #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
jdatcmd added a commit that referenced this pull request Sep 13, 2026
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
jdatcmd added a commit that referenced this pull request Sep 13, 2026
…always-the-last-argument

test: the parity tool's name rule is wrong for four helpers (#432, #1036)
jdatcmd pushed a commit that referenced this pull request Sep 13, 2026
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
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.

2 participants