fix(types,runtime): a declared capability absence is reported once per route per process, at warn - #17854
Conversation
…rocess Maintainer ruling 2026-09-03 (decision batch #23, B + C): a 5xx the platform chose because an optional service is not installed is a configuration fact, not a fault — it is reported once per route per process at `warn`, naming the missing service. Everything else reaching `logServerFault` keeps its per-request `error` line. The predicate is applied inside the shared funnel rather than spelled at each door, so the REST writer and the runtime dispatcher read the same answer by construction. It reuses `declaresServerFault`, the repo's existing declared-5xx vocabulary, and the ADR-0112 code the producer already declared. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
…t must not move Three pins the ruling names: a declared 501 reported once across N requests at `warn`; a second, different route still reporting (the shape a global "first N" throttle would hide); and an undeclared 500 on the same door still loud, once per request. The two-door pin reads the envelope the real dispatcher answers off the wire and hands it to `sendError` — the exit every nested-envelope 5xx in `packages/rest` takes — so both doors must classify one fixture envelope the same way. `vi.resetModules()` per test is load-bearing: the dedupe registry is module state, which is the ruling's "per process" half. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
…level Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
…clared-capability-absence-warn-once
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 2 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 36a8e175321992b2be600079db0fb0b129cae473 && git checkout 36a8e175321992b2be600079db0fb0b129cae473
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2d34f320d1179239d35dfd5c2429761263adad06 44e14e367200bfd7d5d7da30c1489f6481e8f798 && git checkout -B drift-repro 2d34f320d1179239d35dfd5c2429761263adad06 && git merge --no-ff 44e14e367200bfd7d5d7da30c1489f6481e8f798
node scripts/docs-audit/affected-docs.mjs --json 2d34f320d1179239d35dfd5c2429761263adad06
|
Contract review — PR #17854 (card #14656)Head reviewed:
① Derived judgments — every acceptance-set and public-surface change this diff introduces, named and judged1. → no output. That matcher alone is not a reading, so it is backed by controls that fire: the same
The seven new declarations are all module-private, read off the file at head —
2. 3. 4. The wire does not move. — correct, and structurally so. 5. The ruling's execution line says «
6. The three pins the ruling named are present, and the one that matters can fail. The ruling asked for: 「a declared 501 on a polled route logs exactly once across N requests in one process, at 7. All three boundaries of the demotion point the loud way. — correct. A door supplying no route coordinates is demoted but never deduped ( ② semver level —
|
Fixes #14656
Clause-②: no
A declared capability absence — a 5xx the platform chose because the deployment did not install an optional service — is now reported once per route per process at
warn, naming the missing service. Everything else reachinglogServerFaultkeeps the per-requesterrorline #14310 shipped.This is the execution of the maintainer ruling recorded at
5528582503(decision batch #23, 2026-09-03, verbatim 「同意」 to B + C), released for dispatch by the director seat at5644019323.The file face — five paths, and
packages/specis not one of thempackages/types/src/server-fault-log.tswarnbranch insidelogServerFault.packages/types/src/server-fault-log.test.ts503 SERVICE_UNAVAILABLE, which is now the ruled exception — the behaviour that case is about is unchanged and belongs to the fault branch).packages/runtime/src/declared-capability-absence-warn-once.test.tspackages/runtime/src/dispatcher-5xx-always-logged.test.ts/notificationsstill costs anerrorline..changeset/14656-declared-capability-absence-warn-once.md@objectstack/typesand@objectstack/runtime— the packages whose logging changes, which is what the ruling asked for.⛔ Nothing under
packages/spec/is touched.unavailable.tsimportsserviceUnavailableMessagefrom@objectstack/spec/systemand this change needed no edit there. The one new spec reference is a type-only import ofStandardErrorCodeintopackages/types, which proves at compile time that the two code spellings are catalogued ADR-0112 codes.One predicate, one place — and why that is stronger than one import
The ruling's constraint is «⛔ Not spelled once per door: the REST door and the dispatcher read the same predicate». The delivered design applies the predicate inside the shared funnel rather than exporting it for two doors to call:
sendError(packages/types/src/response-envelope.ts:227) is the single exit for every nested-envelope 5xx inpackages/rest;logServerFaultat its three exits (dispatcher-plugin.ts:318, :394, :656).Both already pass through the funnel, so there is no per-door spelling to drift and no door can opt out by forgetting a call. Being unable to spell it twice is stronger than agreeing to spell it once.
It reuses the declared-5xx vocabulary that already exists rather than inventing one:
declaresServerFault(packages/types/src/error-leak.ts:289) is the same "the producer declared this shape" read that@objectstack/rest'sdeclaredServerFaultAnswergates on, and the absence family is the ADR-0112codethe producer already declared (NOT_IMPLEMENTED/SERVICE_UNAVAILABLE).Three boundaries, all fail-loud, all pinned:
errorline even when it declared501. The thrown exit hands the funnel the throw and no envelopecode, so it is not an absence here. The half that carries a stack stays loud.warnbut never suppressed. An un-keyed bucket would collapse every unnamed route into one entry — the same shape as the global "first N" the ruling forbids for hiding the second route.instrumentRouteHandlerparks the route PATTERN, not the raw URL.noThe claim declared
Clause-②: yeswhen it was dispatched, and the dispatch order explained why: the seat read the ruling's ownnoas right about the wire but incomplete, because «the same ruling puts the predicate in@objectstack/typeswhere two packages must read it … so it has to cross a package boundary, and a symbol exported from that package's entry is a new published symbol».That premise does not hold for the delivered design, so the ruling's
nois right and the seat's reading was wrong. The predicate never crosses the boundary — it is applied inside the funnel both packages already call — and every symbol this change adds is module-private. Measured on this branch:git diff BASE..HEAD -- packages/types/src/server-fault-log.ts | grep -E '^[+-][^+-].*\bexport\b'— no output: not oneexportline added or removed.packages/types/src/index.tsandpackages/types/package.json— untouched (0 paths in the diff), so the entry and theexportsmap are byte-identical.packages/types/dist/index.d.ts: six score 0; the single hit forisDeclaredCapabilityAbsenceis a{@link}inside a doc comment at line 1392, andgrep -E "declare (function|const) …"finds no declaration for any of them.✅ Corrected at review, which is the seat's act and not the dev's. The
Clause-②:line at the top of this body now readsno, andneeds:contract-reviewis off both carriers — this PR and card #14656. The record is the contract review at5646183631, written on head44e14e367, which re-measured the surface independently of the reading above and reached the same answer the maintainer ruling had already written down: «Clause-②: no (re-read on the final diff; if any response byte moves, stop and report)».⛔ No wire change — measured, not asserted
The ruling's condition is «if any response byte moves, stop and report». The
#14656 — the wire does not moveblock captures the fullstatus + JSON.stringify(body)for a declared absence (over three requests) and for a fault, and asserts the exact strings.That block is written to be runnable on the pre-change tree: it names no
warn, no count and nothing else this card introduces. Run against the base funnel (packages/types/src/server-fault-log.tsrestored to758ac4097, everything else this branch):mainalready answered — a before/after measurement, not a claim.7ed86cc3…),git diff HEADempty, whole-treegit status --porcelainempty.The ablation — making the two doors disagree
The two-door pin is only worth something if it can fail. Mutation: gate the demotion on
input.request !== undefined, which the dispatcher supplies andsendErrordoes not, so the doors disagree about one envelope.@objectstack/typesrebuilt, thennode scripts/ablation-dist-preflight.mjs @objectstack/types 'ABLATION_DOOR_SPLIT'→ exit 0, marker live indist/index.jsanddist/index.mjs.door 2 must not disagree with door 1 about the same envelope: expected [] to have a length of 1 but got +0.7ed86cc3…, whole-treegit statusclean, rebuild,--absentpreflight exit 0 (marker absent from all 12 built files).A first attempt planted the marker inside a
/* comment */, which esbuild strips, so the plant-leg preflight exited 1 on a marker-choice artifact; the run above replaces it with a marker that survives bundling. Both runs went red on the same assertion.Verification
All at
44e14e367(this branch merged withorigin/mainat3c86008e2). Exit codes captured before any pipe; each verdict read from the command's own printed line.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, reconciled with exit codes recorded:58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUN— «a DERIVED zero — all 58 recorded an exit code and none of them is 3».PREREQUISITE NOT MET, not a finding:check:dual-build-cjs-loadsandcheck:type-check-debtboth read built output. Afterturbo run build --filter='./packages/*' --filter='./packages/*/*'(72/72 successful) both exit 0, the second reporting «5 ledger entr(ies) re-measured in 76.5s, 55 raw tsc error(s) total, none above its recorded number».@objectstack/types:test22 files / 666 tests passed ·test:repo1 file / 7 passed ·typecheckclean.@objectstack/runtime:test261 files / 3631 tests passed ·test:repo2 files / 69 passed ·typecheckclean (which for this package also runscheck:test-typecheck).pnpm lint: exit 0, the fulleslint . --no-inline-configrun — no narrowing to declare.check:nul-bytesgreen, plus an independent control-character scan of all five changed paths: 0 hits.scripts/pm/os-verify-lock.sh; every verdict quoted above is the wrapper'sVERDICT command-exitline.NOT MEASURED, named rather than left to read as green: the five CI jobs
dispatch-gatesreports as scheduled by these paths but having no local invocation (Test Core,Temporal Conformance,Dogfood Regression Gate,Dogfood Verify CLI,Build Core), the five families whose argv takes a value only CI supplies, the 11 declared-wide-population families and the 50 artifact-roster families. No browser/dogfood verification was done: the diff moves no rendered surface and no response byte.The order's line readings — all four held
The dispatch flagged its own re-derived positions as timestamped readings rather than coordinates. Checked by symbol on this tree, all four are correct:
declaredServerFaultAnsweraterror-response.ts:606,boundedDeclaredRefusalMessageat:775,logServerFaultinpackages/types/src/server-fault-log.ts, andcapabilityUnavailableatunavailable.ts:60-61. Nothing to charge back.Acceptance notes
warnbut not deduped.sendErrorpasses onlyrequestId;instrumentRouteHandler— the one producer of the parked route coordinates — is applied solely by the dispatcher's server proxy, andrest-api-pluginmounts its direct-mount registrars on the raw server, so nothing parks coordinates for that door today. Reading them insendErrorwould therefore be dead code. The reachable population is one route (external-datasource-routes.ts:383, a503), which is not polled. Noted, not filed — it is an observation about a door's coordinates, not a reproducible defect, a contract violation, or a metadata-authoring trap.vi.setConfig({ testTimeout: 30_000 })in the new runtime file is paid for the per-test isolation, not hidden slowness: each test re-executes the dispatcher's module graph so the per-process registry starts empty, and the first one measured over 5s on this shared box. Noted, not filed.Generated by Claude Code
Generated by Claude Code