Skip to content

check-durability-degradation-log-level: loggerLevel cannot see the (logger.error ?? logger.warn)(…) fallback, so a loud catch reads as silent-swallow — and the spelling it CAN see prints nothing #9657

Closed

Description

@os-steve

Sub-issue of #8897, which records the receiver-name half of this same function's narrowness. This is the call-shape half. Filed unassigned. Not a claim.

Found while implementing #9609, which adds runWideningAlters to DURABILITY_CRITICAL_CALLEES. Unlike #8897's finding, this one is not latent — it produced a false red on real, loud code, and it is measured rather than reasoned.

Note for triage: #8897's own restart-when trigger reads "any PR touches scripts/check-durability-degradation-log-level.mjs" — the #9609 PR does.

The blind spot

loggerLevel() requires the call expression's callee to be a property access:

const expr = node.expression;
if (!ts.isPropertyAccessExpression(expr) || !ts.isIdentifier(expr.name)) return undefined;

(this.logger.error ?? this.logger.warn)(msg, meta) is a call on a parenthesized expression, so the guard returns undefined and the catch collects no levels at all. The gate then reports it as catch swallows the failure with no log at all — the harshest verdict in the file, on a site that is loud at runtime.

Measured, with the reproduction

On the #9609 branch, with runWideningAlters in the vocabulary and both widening catches calling the fallback inline:

✗ 2 durability-critical catch(es) degrade quietly
  packages/drivers/driver-sql/src/sql-driver.ts:8216
    found   : catch swallows the failure with no log at all

Routing the same two calls through a named same-file helper whose body is if (this.logger.error) this.logger.error(...) else this.logger.warn(...) turns them green and correctly classified:

✓ durability-degradation log levels: 29 durability-critical catch seam(s), all loud …
  packages/drivers/driver-sql/src/sql-driver.ts:8255 → recovers on one branch, loud (error@4021 via logDurabilityFailure())

Why this direction of error is the dangerous one

⛔ The spelling the matcher does accept is this.logger.error?.(…) — an optional call, whose callee is a plain property access. It is by far the cheapest way to turn this red green, and against a sink that has no error it prints nothing at all. SqlDriver.logger declares error optional by design, and hosts do inject { warn } sinks — the #9609 test fixture was one until that PR changed it.

So the gate's cheapest satisfaction here converts a loud degradation into a genuinely silent one. That is the same "a gate whose cheapest satisfaction is harmful has the wrong shape" argument the file's own FAILURE_PROPAGATION_CALLEES header makes about bolting on a logger.error. Measured: with the helper body replaced by this.logger.error?.(msg, meta), the gate stays green while the no-error-sink test goes red.

The population

7 call sites use the fallback idiom today, none currently reachable by the vocabulary, so nothing is red on main:

The idiom exists because logger.error is optional on these driver classes. It is the correct way to write the call; the matcher is what cannot read it.

Options, none free

  1. Leave it, note it in the header next to the file's other honest limitations, and let the same-file-helper indirection be the documented answer — which is what 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 did, and which check-durability-degradation-log-level: collectLoggedLevels only recognises a logger named logger/log/console, so a catch that reports through an injected logger reads as silent to BOTH rules #8897's author gave a weak preference to for the sibling case.
  2. Teach loggerLevel the fallback chain: for a call whose callee is a parenthesized ??/||, collect the level of every branch. (error ?? warn) then contributes both, and the existing levels.filter(LOUD) decides — no new policy, since the file already passes a catch containing both a warn and an error. Does not cover turso-driver.ts's .call(…) spelling.
  3. Declare the fallback as a propagation-style vocabulary entry, which is a third declared list to keep from going stale.

Weak preference for (2): it introduces no new declared names, reuses the file's existing "a loud level anywhere in the catch counts" semantic, and removes the pressure toward the harmful ?. spelling. But it IS a loosening of a gate matcher and should be measured against the whole scan population before landing — the file's own 收窄先行 discipline.

⛔ Deliberately not resolved inside the #9609 PR: choosing among these is a policy call on a gate's classification surface, and #9609's contained answer (a named helper the gate already follows) needed none of it.

Related

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    domain:devxDomain lane — seat card indexed by label:pm:seat
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 18, 2026
  2. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor

    Claiming this card.

    File surface I expect to touch: scripts/check-durability-degradation-log-level.mjs (the loggerLevel() matcher and its self-test harness). Nothing under packages/drivers/** unless the census argues for it — per the dispatch ruling, the 7 existing call sites are not red today and will not be swept.

    Order of work per the dispatch: H1 census of call shapes first (design follows the census, not the other way round), H2 verify the logDurabilityFailure helper indirection really survives the matcher on main, then a chosen option, then the H3 demonstration of what happens to the logger.error?.(…) harmful gradient. Read #9165's PM disposition (Q4, the six-instance family of "recognizer narrower than the repo's shapes, shortfall reported as a verdict rather than as unrecognised") before deciding.


    Generated by Claude Code

  3. removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 18, 2026
  4. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor
    {
      "issue": 9657,
      "status": "done",
      "branch": "claude/issue-9657-logger-level-matcher",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9750",
      "premise_still_valid": true,
      "summary": "H1 FIRST — the census says SIX shape families, not three, so a contained matcher extension was the wrong shape on its own. Across packages/** (non-test .ts): 3,308 log-emitting calls, 658 inside a catch. Visible to the old matcher: `logger.error(...)` and its `?.` variants (3266 / 645 in-catch). Invisible: `(l.error ?? l.warn)(...)` 6/2; `(l.error ?? l.warn).call(l, ...)` 1/0 (driver-turso); `((c.warn ?? c.error))?.(...)` 1/0; a `.bind()` fallback stored in a const and called through the local 8/6 — a FOURTH family the card did not name, in db-job-adapter and two trigger packages, two of whose catches reason explicitly about the durability class in comments; bare `warn(...)`/`info(...)`/`log(...)` helpers 26/5. OPTION TAKEN: option 2 EXTENDED — resolve the callee structurally (parens, ??/||, ternary, .call/.apply/.bind, non-null, logger['error'], same-file const alias) AND stop accepting the one spelling that prints nothing. Option 1 was falsified by the census (five spellings by authors with no reason to know a gate exists; documenting a blind spot does not stop the sixth); option 3 adds a third staleness-prone vocabulary for nothing option 2 lacks. HARMFUL GRADIENT CLOSED: 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. Receiver optionality (`logger?.error(...)`) is deliberately NOT judged — no alternative sink exists, and judging it would have flagged 22 more in-catch calls with no remedy. Also, per H1's own instruction at that count, 'I could not recognise this callee' is now the distinct verdict `unreadable-report` instead of being folded into `silent-swallow`; it fails the gate but accuses the right thing and its remedy text rules `?.` OUT rather than inviting it. ⭐ TWO THINGS THE CARD DID NOT ANTICIPATE. (1) The tightening found SIX REAL SEAMS, not collateral: plugin-audit x3, plugin-email, plugin-security x2, every one on a sink whose `error` is DECLARED OPTIONAL (SweepLogger, ProjectionLogger — whose own comment calls `error` the 'durability-degradation channel'), i.e. genuine instances of the defect this card is about. Repaired locally in the same defect class using #9665's landed shape (reach for error, fall back to warn, never to silence). (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; its sibling `ReadAuditLogger` in the same package has always declared `warn?`. An optional `error` with no declared alternative is a CONTRACT THAT PERMITS SILENCE, and the sink type is a fourth axis for the #9165 family census alongside the call shape. Added `warn?` mirroring the sibling — purely additive. The read-seam rule is passed no `unreadable` sink, so its census and verdicts are byte-identical to main.",
      "tests": "All anchored to the final commit 3a6a4b20a (merged origin/main @ 6cb88d9f2 mid-run after main touched audit-writers.ts; census re-measured on the merged tree and the header table updated to match). GATE: `node scripts/check-durability-degradation-log-level.mjs` => '✓ 29 durability-critical catch seam(s), all loud, rethrowing or propagating (5 propagating, declared)' + '✓ 66 read seam(s), none invents an unreported answer (7 type-discriminated) (1 pass an input through, reported) (1 baselined)' — the read-seam line is byte-identical to main's. SELF-TEST: '✓ 51 case(s) passed' (16 new) + '✓ 35 case(s) passed'. Every new PASSING case pins expectSeams:1 so it cannot pass vacuously; every new FLAGGING case pins expectKinds — a boolean 'did it flag' cannot separate a right verdict from a wrong one, which is this card's whole subject, so the harness gained expectKinds. H3 RUNTIME: against a sink `{warn}` with no error — `l.error?.(msg)` => '(NOTHING EMITTED)'; `(l.error ?? l.warn)(msg)`, `.call(l,msg)`, and `if (l.error) ... else l.warn(...)` => 'warn: durability failure'. H3 GATE, two-direction ablation on the real repo, main's matcher (extracted via `git show origin/main:` and run from scripts/) vs mine: (a) #9665's two sites written INLINE as the fallback => MAIN: '✗ 2 durability-critical catch(es) degrade quietly ... found: 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: '✗ ... found: catch reaches `error` only CONDITIONALLY (error?.@4022 via logDurabilityFailure())'. Both mutations reverted with `git checkout --` from the commit, `git status` empty after. H2 CONFIRMED on main: '--list' prints 'sql-driver.ts:8258 guards runWideningAlters()@8253 → recovers on one branch, loud (error@4022 via logDurabilityFailure())' — 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 helper is a sanctioned shape, not a sufficient answer. PACKAGE SUITES (flock-serialized, dependency closure built first with `pnpm --filter '<pkg>^...' build`): plugin-audit 'Tests 270 passed (270)', plugin-email '418 passed (418)', plugin-security '1293 passed (1293)'; `typecheck` EXIT=0 on all three (it first caught the missing `warn` on AuthEventAuditLogger — a real find, not noise). REVERSE-VERIFICATION of the new tests, predicted direction RED and observed RED: reverting the three source repairs with `git checkout origin/main -- <3 files>` (tests untouched) => 'AssertionError: expected [] to have a length of 1 but got +0' (plugin-audit), 'expected undefined to be defined' (plugin-security), plugin-email x — real exit 1 read without a pipe; restored with `git checkout HEAD --`, byte-exact, all three green again. GATE UNION on the final commit, derived with `node scripts/pm/dispatch-gates.mjs <changed paths>` rather than recalled: check:durability-log-level, check:nul-bytes (plus a manual control-byte grep over my diff, clean), 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 ('1 declaring changeset(s) added') — all exit 0. check:i18n first printed 'PREREQUISITE NOT MET — the workspace CLI is not built ... Nothing was checked', which is a refusal and not a pass, so I built @objectstack/cli and re-ran: 'OK (9 package(s) — all bundles in sync)', EXIT=0 read with echo, not through a pipe. NOT run locally: check:type-check-debt --re-measure (needs the whole workspace closure built; left to CI).",
      "open_questions": [
        {
          "question": "Should a sink that declares the durability channel be allowed to declare `error` OPTIONAL with no required alternative? Every seam this card's tightening found was on such a type, and `AuthEventAuditLogger` declared no `warn` at all — the call site could not have been written correctly against the contract it was given. This is the sink-TYPE axis of the same defect, and it is not settled by this PR.",
          "options": [
            "A — leave the sink types as they are (optional `error`, now with a `warn` beside it everywhere) and let the gate keep judging the call sites. Cheapest; the contract still permits a silence the gate then has to catch case by case.",
            "B — require `warn` on any sink type that declares an optional `error`, enforced by a small gate over the logger-ish interfaces. Declared = enforced: it becomes impossible to hand code a channel with no fallback. Costs a new gate and a scan population that has to be drawn.",
            "C — make `error` REQUIRED on these sink interfaces (the kernel `Logger` already requires it). Strongest, and it deletes the whole fallback idiom — but it is a breaking change to three exported plugin types, and it forecloses the legitimate `{ warn }`-only host the drivers were explicitly written for."
          ],
          "recommendation": "B, but not now and not by me. On real business need: hosts DO inject reduced sinks (SqlDriver's `error?` exists for exactly that), so C's premise is false today and A leaves a live foot-gun. On long-term soundness: B is the contract-first version — the defect is in the producer (the type), which is where AGENTS.md says to fix it, rather than in per-site vigilance. On making AI-written code hard to get wrong: an AI writing a new durability report reads the interface, sees `error?` and nothing else, and writes `error?.(…)` — B is what makes that impossible at the point of authoring rather than catching it one gate-run later. Not in this PR because 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 it is a lane-PM call, not a dev call."
        }
      ],
      "out_of_scope_findings": [
        "filed as #9748: two sibling `logger?.error?.(…)` SUMMARY reports (outbox-sweep.ts:227, permission-set-projection.ts:1067) that no catch guards, so no gate can see them — after this PR the per-row failure lands at `warn` on an error-less sink while the summary counting those same failures stays silent"
      ]
    }

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seatpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions