fix(rest): route probeMcpServeable through the shared environment entry point so /discovery's mcp advertisement follows the request's kernel - #9291
Conversation
…ry point (#9120) `resolveRequestEnvironmentId` is, by its own doc-comment, "THE single entry point for every unscoped-route environment decision ... so they can never disagree about which kernel a request belongs to". Eight consumers go through it; `probeMcpServeable` -- the ninth site needing the request's environment, and the one deciding whether /discovery advertises `routes.mcp` -- re-derived its own from `params.environmentId` else `defaultEnvironmentIdProvider`. That is the shared chain minus the host's ADR-0006 `kernel-resolver` seam and the legacy hostname / X-Environment-Id steps. Single-environment boots agreed anyway (the default provider is step 3 of the shared chain), so the defect was multi-tenant-only: on a hostname-routed host an unscoped /discovery request has no `params.environmentId` and no default provider is registered, so the probe fell through to `serviceExistsProvider` -- the HOST kernel's answer, not the request environment's. Both misadvertisement directions were reachable and are now pinned. The `'platform'` guard and the `serviceExistsProvider` fallback are unchanged. The unsubstituted `':environmentId'` route pattern is normalised to "no id" before the call, since the entry point short-circuits on any truthy explicit value. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
📓 Docs Drift CheckThis PR changes 1 package(s): 1 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 seeCoarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop):
|
Fixes #9120
What was wrong
RestServer.resolveRequestEnvironmentIddescribes itself, in its own doc-comment, asRe-counted on
origin/main@b0fa4fc1arather than taken from the card: eight call sites go through it —resolveEndpointMatchAuthority,resolveMetadataService,resolveProtocol,resolveI18nService,computeExecCtx,resolveSecurityService, the analytics service resolver and the security-explain service resolver. The card's count still holds.probeMcpServeableis the ninth site that needs the request's environment — and the one whose answer decides whether/discoveryadvertisesroutes.mcp. It re-derived its own:That is the shared chain minus its first and middle steps: the host's ADR-0006
kernel-resolverseam, and the legacy hostname /X-Environment-Idchain beneath it. This isdeclarednot equal toenforcedin the very code #4024 added to close that class.Single-environment boots were correct throughout, which is why this never surfaced: there
defaultEnvironmentIdProvideris registered, and it is also step 3 of the shared chain, so both spellings agreed. The defect is multi-tenant-only — on a hostname-routed host an unscoped/discoveryrequest carries noparams.environmentId, and no default provider is registered (that iscreateSingleEnvironmentPlugin's wiring). Neither input the probe read was present, so it fell through toserviceExistsProvider: the host kernel's answer, not the request environment's.The fix
probeMcpServeablenow callsresolveRequestEnvironmentIdlike its eight siblings. Per the triage scope, the'platform'guard and theserviceExistsProviderfallback are unchanged.One addition the swap requires: the unsubstituted
':environmentId'route pattern is normalised to "no id" before the call. The entry point short-circuits on any truthy explicit value, so passing the pattern through would have handed it togetOrCreateas if it were a kernel id. The pinnever mistakes the literal ":environmentId" placeholder for an environmentcovers this, and it was one of the seven that failed pre-fix.This also makes good the parity the probe's doc-comment already claimed with
resolveRegisteredServices, whose kernel arrives asctx.__kernel— set downstream of the same entry point, which is why that sibling was never exposed to this.Reverse verification — direction predicted before running
Predicted: the four multi-tenant reachability pins, the placeholder pin and both
/discoveryend-to-end pins go red against unfixed code; the single-env,'platform', fallback andnullpins stay green (they encode behaviour that was already correct).Observed, tests written first and run against unfixed
packages/rest/src/rest-server.ts— 7 failed / 13 passed, exactly the predicted split:Both misadvertisement directions are therefore measured, not argued:
mcp, request's environment does not — pre-fix/discoveryreturnedroutes.mcp = '/api/v1/mcp'for an environment whose route 501s (expected '/api/v1/mcp' to be undefined);expected undefined to be '/api/v1/mcp').mcpServeable !== falsefails open only for anullprobe, never for a confidentfalsecomputed against the wrong kernel.After the fix: 20 passed (20) in that file.
The
reqconfirmation the card asked forConfirmed — no finding. The
/discoveryhandler passesprobeMcpServeable(req)the same handlerreqthe other resolvers receive, unmodified. The only other request-derived values in that handler aregetDirectMountRouteBases(...)andgetMountedEmailRouteBase(...), which take an environment-id string for route-template substitution and are not environment resolvers.The two
/discoveryend-to-end pins prove it empirically rather than by reading: they drive the real registeredGET /api/v1/discoveryhandler with a request carrying only aHostheader, and the advertisement follows the resolution chain — which it can only do if thereqreaching the probe still carried the headers that chain reads.Tests
New pins live in
packages/rest/src/rest-env-resolution.test.ts, the existing ADR-0076 D11 step 4 seam file — the right home, since this is that seam's ninth consumer. The harness'sbuildRestgained aserviceExistsProviderseam (it was hard-wiredundefined), which is what lets a test state "the host says yes and the environment says no" at all.Verification at
6e7fe991d(the head this PR pushes):pnpm --filter @objectstack/rest typecheck— passpnpm --filter @objectstack/rest test— 122 test files, 2022 tests, all passednode scripts/pm/dispatch-gates.mjsover the actual changed paths, all green:check:authz-resolver,check:route-envelope,check:dispatcher-error-vocabulary,check:cross-package-test-inputs,check:changeset-gate-self-tests,check:objectui-changeset,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check:affected-docscheck:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage, pluscheck:nul-bytescheck:route-envelopereports its pre-existing#7035ratchet warnings onrest-server.ts(stringError 44, siblingCode 69) and exits 0 — untouched by this diff, and the error-envelope seam is deliberately out of scope here.Scope
Environment-resolution seam only. #9232 and #9267 are not addressed here — no error-envelope code is touched.
A same-class observation found while verifying is filed separately rather than fixed here: the
/discoveryhandler builds its document fromthis.protocol.getDiscovery()(the control-plane protocol) rather thanresolveProtocol(environmentId, req), on the scoped route too. That is a different seam with a much wider blast radius than this card's, so it gets its own issue.Generated by Claude Code