issue #3386 · 2026-08-10

The DI was wired. The logs still went nowhere.

509 ctx.log() sites across 164 files were reported as no-ops, and the obvious reading was that HookContext dependency injection had never been connected. It had been connected since v7.30.0. The defect was two defaults sitting on opposite sides of one comparison, which is why the wiring looked absent from every angle except the gate itself.

The gate

logHook(name, msg, level = 'debug')      <- what a bare ctx.log() sends
getLogLevel()                = 'warn'    <- the threshold it is compared against
shouldLog('debug') vs 'warn' = false     <- so it returned before touching disk

427 of the 503 call sites pass no level at all. Those are the record-keeping calls: the ones that state what a hook decided. Every one of them was guaranteed-dropped in production and landed only when a developer had explicitly set ORCHESTKIT_LOG_LEVEL=debug.

Why it read as unwired

Both failure modes produce an empty file

A hook whose ctx was never injected writes nothing. A hook whose ctx is injected correctly but whose level loses the comparison also writes nothing. Nothing in hooks.log distinguishes them, so the absent-output symptom pointed at the wrong layer and the real one-line cause survived every earlier pass.

The fix, stated as a rule

knobwasnowwhy
logHook default leveldebuginfobare calls are records, not verbosity
getLogLevel() defaultwarninfothe gate must admit the default level
stale-team DELETE recordimplicitexplicit warnsurvives an operator-tightened level

The invariant worth keeping: the default level and the default threshold must stay on the same side of the gate. 'debug' remains opt-in verbosity; it is no longer the level that record-keeping accidentally used.

The test that would have caught it

Assert on disk, at default level, through the real dispatcher

Unit-testing ctx.log with a mock proves the call happened, not that anything landed. Section 9 of the dispatcher suite backdates a team fixture 48h, runs the real destructive stale-team-cleanup hook through the real built bundle with no log-level env set, then asserts the physical bytes.

9. HookContext logging lands on disk (#3386)
   stale team actually deleted (destructive action executed)
   DELETE record landed on disk at default log level
   bare ctx.log() summary line landed (info admitted by default gate)

The first assertion exists so the other two cannot pass vacuously: if the fixture stops being stale, the hook never deletes, and a log assertion against an empty file would otherwise be meaningless rather than failing.

Verification

dispatcher suite   29 passed, 0 failed
npm test --quick   25 categories passed, 0 failed
tsc --noEmit       clean
npm run build      plugins regenerated, only the 4 source files + dist churn

Class of bug, for the v10 ledger: this is a false signal, not a missing feature. The machinery existed, ran, and reported nothing, so every session that debugged through these hooks was reading an empty file as evidence of quiet success. Sibling instances in the same milestone are hooks registered but never dispatched, guards that pass because they cannot observe, and telemetry fields that look like measurements. The repair is the same shape each time: assert the effect, not the call.