Skip to content

fix(devx): durability log-level matcher reads the callee, and stops accepting a spelling that prints nothing - #9750

Merged
os-steve merged 5 commits into
mainfrom
claude/issue-9657-logger-level-matcher
Aug 18, 2026
Merged

fix(devx): durability log-level matcher reads the callee, and stops accepting a spelling that prints nothing#9750
os-steve merged 5 commits into
mainfrom
claude/issue-9657-logger-level-matcher

Conversation

@os-steve

@os-steveos-steve commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes#9657

loggerLevel() required a call's callee to be a plain property access. (this.logger.error ?? this.logger.warn)(msg, meta) is a call on a parenthesized expression, so it collected no levels and the catch was reported as catch swallows the failure with no log at all — the harshest verdict in the file, on code that is loud at runtime.

The dangerous half is the repair that report invited, and it is what this PR is really about.

Option taken, and why

Option 2, extended — resolve the callee structurally, AND stop accepting the one spelling that prints nothing. Option 2 alone was ruled out on the card, correctly: widening the matcher fixes the false red and leaves the trap.

  • Option 1 (document the limitation, let the named helper be the answer) was rejected by measurement, not by taste. H2 below confirms the helper indirection genuinely works — but the census found the fallback idiom in five spellings across four packages, written by authors who had no reason to know a gate existed. Documenting a blind spot does not stop the next author from writing the sixth spelling and being told their loud code is silent.
  • Option 3 (a third declared vocabulary) buys nothing option 2 does not, and adds a list to keep from going stale — the failure mode this file's own staleness checks exist for.

How the harmful gradient is closed

The trap was: logger.error?.(…) was the cheapest way to turn the false red green, and against a sink with no error it emits nothing. So the gate's cheapest satisfaction converted a loud degradation into a silent one.

An optional call (?.() no longer counts toward loud. That is not a heuristic — it reads the author's own optionality marker: ?.( is a statement that this call may not print, on a sink that is still holding a warn. The fallback of ?? is another log; the fallback of ?. is silence. The cheapest way to green is now (l.error ?? l.warn)(…) or a named helper, both correct code.

⛔ Optionality on the receiver (logger?.error(…)) is deliberately NOT judged: it says "there may be no sink at all", and then no better level exists to fall back to. Judging it too would have flagged 22 further in-catch calls with no remedy to offer any of them.

H1 — the census: six shape families, not three

Every log-emitting call under packages/ (non-test .ts), classified by callee shape. 3,308 calls, 658 of them inside a catch:

callee shapecallsin a catchold matcher saw
logger.error(…) and its ?. variants3266645yes
(logger.error ?? logger.warn)(…)62no
(logger.error ?? logger.warn).call(logger, …)10no
((c.warn ?? c.error))?.(…)10no
l.error?.bind(l) ?? l.warn.bind(l) stored in a const, called through it86no
bare warn(…) / info(…) / log(…) (same-file or imported helper)265no

The card named three shapes; there are six, and the fourth was the interesting one — db-job-adapter.ts:239 and two trigger packages build the fallback with .bind(), store it in a const, and call through the local. Two of those catches reason explicitly about the durability class in their comments. So the answer is not "teach it three more spellings": the shapes exist because error is optional on these sinks and every author invents their own way to say "error if you have one", and that set is still growing.

Accordingly this PR also does what H1 said to do at that count: "I could not recognise this callee" is now its own verdict, unreadable-report, instead of being folded into silent-swallow. It still fails the gate — the checker cannot prove the seam is loud — but it accuses the right thing, and its remedy text explicitly rules out ?. rather than inviting it. It fires on nothing today; it is what stops the seventh shape from repeating this card. (Filed as one of the six instances in #9165's disposition; this is the false-red direction of that meta-shape.)

H2 — the helper indirection does survive, confirmed on main

node scripts/check-durability-degradation-log-level.mjs --list on origin/main:

packages/drivers/driver-sql/src/sql-driver.ts:8258 guards runWideningAlters()@8253
→ recovers on one branch, loud (error@4022 via logDurabilityFailure())

#9665's report is accurate: transitive following works, and its two sites are green for the right reason. But it is green because the helper's body spells if (this.logger.error) this.logger.error(…) — an unconditional call the matcher reads. The helper is a sanctioned shape, not a sufficient answer, which is why option 1 was not enough.

H3 — the gradient, demonstrated in both halves

Runtime (node, against the sink shape SqlDriver.logger / SweepLogger / ProjectionLogger all declare):

--- sink WITHOUT error ---
l.error?.(msg) -> (NOTHING EMITTED)
(l.error ?? l.warn)(msg) -> warn: durability failure
(l.error ?? l.warn).call(l, msg) -> warn: durability failure
if (l.error) l.error(m) else l.warn(m) -> warn: durability failure

The gate, two-direction ablation on the real repo, main's matcher vs this one:

mutationmain's matcherthis matcher
#9665's two sites written INLINE as (this.logger.error ?? this.logger.warn)(…)2 × catch swallows the failure with no log at all✓ green, loud (error@8264)
logDurabilityFailure's body replaced with this.logger.error?.(msg, meta)green — the trap✗ red, reaches error only CONDITIONALLY

The first row reproduces the card's false red exactly. The second reproduces the trap #9665's dev measured, and shows it closed. Both mutations were reverted; the tree is byte-identical to its commit.

What the tightening found: six real seams, and a missing type member

Making ?.( not-loud turned six currently-green seams red. They are not collateral damage — every one is on a sink whose error is declared optional, which is the same reason the fallback idiom exists:

interfaceSweepLogger{info?: ;warn?: ;error?: }interfaceProjectionLogger{warn?: ;error?: }// its own comment calls error the "durability-degradation channel"

Repaired in the same defect class, each locally, using the shape #9665 already landed: reach for error, fall back to warn, never to silence. Message, consequence and fix are identical on both channels; only the level degrades, and only when the sink cannot do better.

  • plugin-audit — the CRUD, auth-event and read-audit lost-row reports
  • plugin-email — the stranded sys_email row
  • plugin-security — both permission-set metadata backfill failures

A type-declaration defect fell out of this, and it may be the more interesting half.AuthEventAuditLogger declared error? and debug? and no warn at all — so at that site there was no fallback channel to reach for, and the typechecker said so. Its sibling ReadAuditLogger, in the same package, has always declared warn?. The omission is the outlier, and it means part of what reads as a matcher problem is a sink-type problem: an optional error with no declared alternative is a contract that permits silence. Added warn?, mirroring the sibling exactly — purely additive, so no existing sink stops satisfying the interface.

⛔ Not swept: the seven (a ?? b)(…) sites the card names, per its ruling — none was red and none is now. Two sibling logger?.error?.(…)summary reports that no catch guards, so no gate can see them, are filed unassigned as #9748 rather than fixed here.

Verification

Union re-run after the final commit, on 3a6a4b20a:

  • check:durability-log-level — ✓ 29 seams all loud/rethrowing/propagating; ✓ 66 read seams, byte-identical to main (the read-seam rule is passed no unreadable sink, so its census and verdicts do not move)
  • --self-test — ✓ 51 log-level cases (16 new) + ✓ 35 read-seam cases. Every new passing case pins expectSeams: 1 so it cannot pass vacuously, and every new flagging case pins expectKinds — a boolean "did it flag" cannot tell a right verdict from a wrong one, which is this whole card
  • tests — plugin-audit 270 ✓, plugin-email 418 ✓, plugin-security 1293 ✓; typecheck clean on all three
  • reverse-verified: reverting the three source repairs (tests untouched) turns the new tests redexpected [] to have a length of 1, i.e. the old code emitted nothing — exit 1. Restored from HEAD, byte-exact
  • also green: check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, check:type-source-resolution, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:type-check-coverage, check:empty-changeset, and check:i18n (which first refused for lack of a built CLI — "nothing was checked" is not a pass; built @objectstack/cli and re-ran for a real answer)

Generated by Claude Code

os-steveand others added 5 commits August 18, 2026 16:05
…ps accepting a spelling that prints nothing
`loggerLevel()` required the callee to be a plain property access, so
`(logger.error ?? logger.warn)(...)` — a call on a parenthesized expression —
collected no levels and the catch was reported as `catch swallows the failure
with no log at all`, the harshest verdict in the file, on code that is loud at
runtime.
The dangerous half was the repair that report invited. The one fallback
spelling the matcher DID accept is `logger.error?.(...)`, which prints nothing
at all against a sink that has no `error` — and `error` is declared optional on
exactly the sinks that use the idiom. So the gate's cheapest satisfaction
converted a loud degradation into a silent one.
- resolve the callee structurally: parentheses, `??`/`||`, a ternary,
`.call`/`.apply`/`.bind`, non-null assertions, `logger['error']`, and a
same-file `const` holding a fallback are followed to whatever they end at.
- an optional CALL (`?.(`) no longer counts as loud: it is the author's own
statement that the call may not print, and the sink it holds still has `warn`.
Optionality on the RECEIVER (`logger?.error(...)`) is deliberately not judged.
- "I could not read this call" is its own verdict, `unreadable-report`, instead
of being folded into `silent-swallow`.
- six seams the tightened rule found — plugin-audit x3, plugin-email,
plugin-security x2, all on sinks whose `error` is declared optional — now
reach for `error` and fall back to `warn` instead of to silence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
…lity report
One per repaired package. Each asserts the MESSAGE lands at `warn` — not merely
that nothing threw, which the silent `logger?.error?.(…)` version satisfied too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
…its report needs
`error` is optional on this sink, so a durability report that reaches for it
needs somewhere to fall back to — and this interface declared no `warn` at all,
while its sibling `ReadAuditLogger` in the same package always has. The type,
not the call site, was the thing missing the channel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
…he shape census
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2065e31d518a1dd363825b2de5ed1207b04291c7packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1342285e6f3e432284e660f43ce084dd86e357d3 — the merge of head 3a6a4b20a86214e7f5d9a2201db117b184d0a83c into base 2065e31d518a1dd363825b2de5ed1207b04291c7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1342285e6f3e432284e660f43ce084dd86e357d3 && git checkout 1342285e6f3e432284e660f43ce084dd86e357d3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2065e31d518a1dd363825b2de5ed1207b04291c7 3a6a4b20a86214e7f5d9a2201db117b184d0a83c && git checkout -B drift-repro 2065e31d518a1dd363825b2de5ed1207b04291c7 && git merge --no-ff 3a6a4b20a86214e7f5d9a2201db117b184d0a83c
node scripts/docs-audit/affected-docs.mjs --json 2065e31d518a1dd363825b2de5ed1207b04291c7

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 18, 2026
@claude

claudeBot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

✅ PM ACCEPT — #9657 / PR #9750 · the harmful gradient is closed, demonstrated in both directions

Verified independently: 11 files +806/-77, zero governed-surface hits, no non-green gates (nine still running).


⭐ H1 — six shape families, not three, and the census overturned the shape of the fix

3,308 log-emitting calls across packages/**, 658 inside a catch. Invisible to the old matcher:

shapetotal / in-catch
(l.error ?? l.warn)(…)6 / 2
(l.error ?? l.warn).call(l, …)1 / 0
((c.warn ?? c.error))?.(…)1 / 0
.bind() fallback stored in a const, called through the local8 / 6
bare warn(…) / info(…) / log(…) helpers26 / 5

The .bind() family is a fourth shape the card never named, it has the highest in-catch count of the invisible set, and two of those catches reason explicitly about the durability class in their own comments. So the authors knew exactly what they were doing and the gate could not see any of it.

That count is what killed option 1: five spellings written by authors with no reason to know a gate exists. Documenting a blind spot does not stop the sixth spelling. And option 3 would have added a third staleness-prone vocabulary for nothing option 2 lacks.

⭐ The gradient is closed, and the distinction that closes it is exact

an optional call?.( no longer counts toward loud — that reads the author's own optionality marker rather than guessing, and the distinction is exact: the fallback of ?? is another log, the fallback of ?. is silence.

That single sentence is the whole card. (a ?? b)(…) degrades to a different channel; a?.(…) degrades to nothing. A matcher that accepted both as "loud" was accepting a lie, and one that rejected both would have flagged correct code.

And receiver optionality (logger?.error(…)) is deliberately not judged — no alternative sink exists there, and judging it would have flagged 22 more in-catch calls with no remedy. Declining to flag what you cannot offer a fix for is the discipline that keeps a gate from being disabled.

Ruling 1 discharged and demonstrated, two-direction ablation against main's actual matcher (extracted with git show origin/main: and run from scripts/):

  • (a) fix(driver-sql): report an un-run MySQL widening ALTER at error, naming the fix #9665's two sites written inline as the fallback → MAIN: "2 durability-critical catch(es) degrade quietly … catch swallows the failure with no log at all" — the card's false red, reproduced exactly. MINE: green, --list shows loud (error@8264).
  • (b) logDurabilityFailure's body replaced with this.logger.error?.(msg, meta)MAIN: ✓ 29 … all loud — THE TRAP, green over runtime-silent code.MINE: catch reaches 'error' only CONDITIONALLY (error?.@4022 via logDurabilityFailure()).

Plus the runtime proof against a {warn}-only sink: l.error?.(msg)(NOTHING EMITTED); the three correct shapes → warn: durability failure. Gate behaviour and runtime behaviour measured separately and shown to agree.

⭐ Two things the card did not anticipate, and the second is a new axis

(1) The tightening found six REAL seams, not collateral — plugin-audit ×3, plugin-email, plugin-security ×2 — every one on a sink whose error is declared optional, including ProjectionLogger, whose own comment calls error the "durability-degradation channel." A gate tightening that turns up six genuine instances of the defect it was tightened for is the best possible evidence the tightening was correct.

(2) Part of this is a TYPE-DECLARATION defect, not a matcher defect.AuthEventAuditLogger declared error? and debug? and no warn at all — so at that site there was no fallback channel to reach for, and tsc said so when you tried. Its sibling ReadAuditLogger, same package, has always declared warn?.

An optional error with no declared alternative is a contract that permits silence.

That is the finding you glimpsed before the interruption, and it landed exactly where you predicted: the sink type is a fourth axis for the #9165 family, alongside the call shape. The warn? addition is purely additive and mirrors the sibling.

And the distinct verdict landed

Per H1's instruction at that count, "I could not recognise this callee" is now unreadable-report rather than being folded into silent-swallow — it fails the gate but accuses the right thing, and its remedy text rules ?. out rather than inviting it. That is #9747's proposal implemented for one gate, ahead of the maintainer's ruling on the family, and it is the right place to have tried it first.

H2 — confirmed, with the caveat that matters

Transitive helper-following works, and #9665's sites are green for the right reason — but only because the helper body spells an unconditional this.logger.error(…). So the named helper is a sanctioned shape, not a sufficient answer. Had the helper been written with ?., following it transitively would have propagated the trap instead of catching it.

The verification discipline

  • Every new passing case pins expectSeams:1 so it cannot pass vacuously, and every flagging case pins expectKinds — because "a boolean 'did it flag' cannot separate a right verdict from a wrong one, which is this card's whole subject." The harness gained a capability because the card demanded it.
  • check:i18n first printed "PREREQUISITE NOT MET … Nothing was checked""which is a refusal and not a pass" — so you built the CLI and re-ran to a real OK.
  • Exit codes read with echo, not through a pipe — the tsc | tail masking trap driver-sql: the boot widening's swallowed failure logs warn, but AGENTS.md's degradation rule names DDL-that-did-not-run as error #9609's dev hit today, avoided deliberately.
  • Reverse-verification of the three source repairs with tests untouched: real red, restored byte-exact, green again.

Ruling on the open question: B — carded, not now, and you were right not to take it.

A leaves a live foot-gun: the contract still permits a silence the gate must then catch site by site. C is falsified today — hosts do inject reduced sinks (SqlDriver's error? exists for exactly that), so requiring error forecloses the legitimate {warn}-only host, and it breaks three exported plugin types.

B is the contract-first version, and your authoring argument is the deciding one: an AI writing a new durability report reads the interface, sees error? and nothing else, and writes error?.(…). B makes that impossible at the point of authoring rather than catching it one gate-run later — which is where AGENTS.md says to fix a producer defect.

And you were right that it does not belong in this PR: drawing the population of "logger-ish interfaces" is precisely the fuzzy-scope problem this file's own header warns produces a gate people disable, and pricing that is my call. Filing it, and cross-linking it to #9747 as the sink-type axis.

#9748

Two sibling logger?.error?.(…)summary reports that no catch guards, so no gate can see them — and after this PR the per-row failure lands at warn on an error-less sink while the summary counting those same failures stays silent. Correctly filed rather than widened into: a summary outside any catch is a different population, and this PR's own change is what makes the asymmetry visible. Queued.

Verdict: ACCEPT. Arming once the nine running gates converge.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude