fix(types,runtime): log every 5xx at error level instead of answering it silently - #14654
Conversation
…ad of answering it silently A 500 that leaves no server-side line is diagnosed from the browser or not at all. Measured on `main` @ ca48cf3, through the real plugin and the real route handlers: a plain `Error` thrown out of a dispatcher route answered `500 INTERNAL_ERROR` with zero log records at any level. The reporting that existed was not a substitute: `ErrorReporter` defaults to `NoopErrorReporter` (so a dev server captured nothing), and it is fed by `res.__obsRecordedError`, which only the THROWN exit sets — a route that catches its own fault and RETURNS a 5xx envelope recorded nothing at all. `logServerFault` (new, `@objectstack/observability`) is the one definition of the rule, emitting exactly one `error`-level record with method, path, request id, message and stack. It is wired at each transport's own single exit so a fault costs one line and never two. 4xx stays quiet, decided inside the helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…utes import `check:check-system-context-census` went red on pure line rot: the `logServerFault` import added one line to `packages/rest/src/package-routes.ts`, shifting both anchored elevation reads by one (97 -> 98, 102 -> 103). Repaired with the gate's own `--fix`; no prose changed and no row added or removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d land it on sendError Re-homed under the domain seat's serial fence: `packages/rest/src/package-routes.ts` is held by the open PR #14499, so this no longer edits that file. The REST direct-mount doors are covered from the producer side instead. `sendError` (`@objectstack/types`) is the single writer for every nested-envelope error in the repo, and every catch in the package registrar ends there — so wiring the rule at that one exit covers those doors with no per-door call, and covers any door added later by construction. That also puts the helper in the same package as `resolveThrownHttpError`, on the same argument: a rule two doors must agree on cannot live inside one of them. `@objectstack/observability` and `packages/rest` are back to origin/main byte-for-byte, as is the system-context census page (its line rot was caused by the package-routes import this drops). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 25beb23d6384a4a100200ed6882330e0b71ff003 && git checkout 25beb23d6384a4a100200ed6882330e0b71ff003
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 20b883918aa787e65299e72b85c2b3396aa2006f b4bc2ab473e5f8b9d95326e9a191f4ded2ac98e5 && git checkout -B drift-repro 20b883918aa787e65299e72b85c2b3396aa2006f && git merge --no-ff b4bc2ab473e5f8b9d95326e9a191f4ded2ac98e5
node scripts/docs-audit/affected-docs.mjs --json 20b883918aa787e65299e72b85c2b3396aa2006f
|
Fixes #14310
A 500 that leaves no server-side line is diagnosed from the browser or not at all. Reproduced on
main@ca48cf377, driving the real dispatcher plugin and the real route handlers: a plainErrorthrown out of a route answered500 INTERNAL_ERRORwith zero log records at any level.The single
logger.warnin that run was unrelated boot chatter (a fake transport with nosetFallbackHandler), so the fault itself reached nobody at any level.Why the existing machinery did not already cover this
Two independent reasons, both measured rather than reasoned:
ErrorReporter.captureExceptiondefaults toNoopErrorReporter. A dev server — the surface an operator actually watches — wires no APM, so the capture was a no-op every time. A log line is the operator's floor; APM is opt-in telemetry on top of it.res.__obsRecordedError, which only the THROWN exit sets. A route that catches its own fault and RETURNS a 5xx envelope — how every/packageshandler answers, viadeps.errorFromThrown— recorded nothing, so even a wired reporter never saw those. Confirmed in the same run: the thrown path had__obsRecordedError set = true, the returned pathfalse.Where it lands, and why that is the single conversion point
logServerFault(new,packages/types/src/server-fault-log.ts) emits exactly oneerror-level record carrying method, path, request id, the message and — where the door still holds the throw — the stack.It shares a home with
resolveThrownHttpErroron that rule's own argument: a rule two doors must agree on cannot live inside one of them, because@objectstack/runtimedepends on@objectstack/restand an import could only ever point one way. That is why#8016moved the "what status does this throw mean" rule to@objectstack/types; "is this answer worth an operator's attention" is the same kind of rule read by the same two doors.Wired at each transport's own single exit, so a fault costs one line and never two:
sendErrorpackages/types/src/response-envelope.ts/api/v1/packagesregistrarerrorResponseBasepackages/runtime/src/dispatcher-plugin.tssendResultBasepackages/runtime/src/dispatcher-plugin.ts/packagesshape the card was filed onpackages/runtime/src/dispatcher-plugin.tssendErroris the load-bearing choice for the REST side: it is the one writer every catch in the package registrar already ends in, so those doors become loud from the producer side with no per-door call — and a door added later cannot forget one.packages/rest's/datadoors were already loud vialogUnexpectedRouteErrorand are untouched.Request coordinates reach the two dispatcher exits through a
res.__obsRequestside-channel parked byinstrumentRouteHandler— the same shape and lifetime as the__obsRecordedErrorchannel that exit has used since #3867. That avoids threading a third argument through the ~50 route handlers that call the locally-shadowedsendResult/errorResponsewrappers.Evidence that 4xx is unaffected
The 5xx test lives once, inside the helper, rather than at each call site — so no call site can drift from it.
isServerFaultpinned at the 499/500 boundary;logServerFaultreturnsfalseand emits on no channel for a 404.sendErrorat409 DESTRUCTIVE_CHANGEand403 FORBIDDEN— the coded refusals that door exists to carry — emit zero lines.errorlevel is load-bearing rather than incidental: the CLI default iswarn(DEFAULT_LOG_LEVEL,packages/cli/src/utils/log-level.ts) anderror(40) outrankswarn(30) inLEVEL_PRIORITY, so the record clears--log-level's default without bypassing the level system. The pins assert the level, not merely that output happened.Reverse verification
Both landings were ablated from a committed tree, each mutation confirmed on disk by anchor count and object hash before the run, and restored by
git checkout HEAD -- ABSOLUTE_PATHverified by emptygit diff HEADplus a worktree/HEAD blob-hash match:errorResponseBase→ the two thrown-path pins fail (expected [] to have a length of 1), the returned-path and 4xx pins stay green;sendError→ only the funnel pin fails (1 failed / 12 passed).Each exit's pin is falsifiable by that exit alone.
Serial fence
Re-derived against the domain seat's fence on the card.
packages/rest/src/package-routes.tsis held by the open PR #14499, so this PR does not touch it — the REST doors are covered from the producer side instead. Verified byte-identical toorigin/mainat this head:package-routes.ts,error-response.ts(PR #14544, merged),dispatcher-error-vocabulary.ts(PR #14625, open).The #7898 H17 rider does not fire: this diff touches none of
packages/core/src/security/auth-gate.ts,packages/runtime/src/http-dispatcher.ts, orpackages/adapters/**.Verification (all at
b4bc2ab47)pnpm --filter @objectstack/types exec vitest run— 19 files, 548 passeddispatcher-plugin*,dispatcher-5xx*,instrument, validation-error, withhold, package-door parity, error-envelope conformance) — 21 files, 285 passedtypecheckontypes+runtime+rest— cleannode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no paths passed) — 35 families, 31 exit 0. The other four exit 3 = PREREQUISITE NOT MET, not findings:check-test-completeness,check-half-states,check:dual-build-cjs-loads,check:type-check-debteach need a full-repo build or GitHub API access and print "nothing was measured". CI runs them.packages/runtime/tsconfig.jsonexcludes**/*.test.ts, sopnpm --filter @objectstack/runtime typechecksays nothing about the new test file (tsc --listFiles→ 0 matches). Both new test files were type-checked explicitly under a temporary config (0 errors) and the temp configs removed.Declared narrowing —
pnpm lint(whole-repo ESLint) was not run. Targeted instead:eslint --no-inline-config --format jsonover all 7 changed.tsfiles — 0 errors, 0 warnings. The three facts that make this a measurement rather than a gap: (a) the file count is read from ESLint's own--format jsonoutput, not estimated; (b) the population is read from the repo's config, which never enables type-aware linting for any file (noparserOptions.project, no typed@typescript-eslintrules —eslint.config.mjs, with its own recorded positive control); (c) therefore no untouched file's verdict can move as a result of this diff.Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usable
flock. The sharedverify lock is declared Linux-only (
flockis util-linux, and a stock macOS doesnot ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.
One consequence worth a maintainer's eye
A deployment answering a declared 5xx on a polled route —
501 NOT_IMPLEMENTEDfrom an uninstalled optional service, e.g./api/v1/notificationswithoutservice-messaging— now prints oneerrorline per request where it previously printed none. This PR implements the band the issue specifies verbatim ("4xx may stay quiet; 5xx never") and deliberately invents no carve-out: narrowing it for declared capability-absence would be a contract decision, not an implementer's call.Generated by Claude Code