fix(rest): /discovery describes the request's environment, not the control plane - #9412
Conversation
…ntrol plane `registerDiscoveryEndpoints`' handler opened with `this.protocol.getDiscovery()` — the control-plane protocol captured at construction — while ~30 sibling handlers in the same file obtain theirs from `resolveProtocol(environmentId, req)`. Everything else in the handler composes over that document, so the whole body followed the host's kernel. The scoped route is the sharper half: the same closure serves the unscoped base and `.../environments/:environmentId`, so a request naming its environment in the URL still received the control plane's document. Measured on a two-kernel host before the fix: two environments with genuinely different kernels received byte-identical `capabilities`, `services` and `locale` — 13/13 capability keys wrong for the richer tenant, its whole `services` map wrong, its `locale` wrong, four real route keys missing and a phantom `routes.notifications` advertised in their place. The unscoped route reaches the same shared resolution and keeps the control-plane answer where it is correct: with no environment in scope, `resolveProtocol` falls through to `this.protocol`. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
`/discovery` now resolves the request's `protocol` off the same per-request kernel the mcp probe uses, so a double resolving every service name to one value handed the mcp stub back for `protocol`. A real kernel serves both slots. The #9120 assertions are unchanged — only the double got faithful. Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) 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 #9292
registerDiscoveryEndpoints' handler opened withthis.protocol.getDiscovery()— thecontrol-plane protocol captured at construction — while roughly thirty sibling
handlers in the same file obtain theirs from
resolveProtocol(environmentId, req).Everything else in the handler composes over that one document, so the entire body
followed the host's kernel.
/discoveryis the surface SDKs, codegen and AI clients read(AGENTS.md "Route and surface ownership" rule 4: machine-readable surfaces must not lie),
and it was the one surface that did not follow the request.
Premise verified by measurement, not argued from source
The card said its reachability claim was argued from the code path, and triage flagged
that as the weak point. It is now measured. A two-kernel host, real
getDiscovery()producers per environment, driven through the real registered route:
Before the fix — the two scoped documents were byte-identical in
capabilities,servicesandlocale, and both carried the host's answers rather than eitherenvironment's. For the richer tenant:
capabilities(13 keys)transactionalBatchautomationcronexportcommentsanalyticsaii18nall truefalsecapabilities.notificationsfalsetrue(the host's service, deliverable by no tenant)servicesmetadata data analytics automation job ai i18nmetadata data notificationlocalezh-CN, supportedzh-CN,enen, supportedenroutesanalyticsautomationaii18nnotificationspresentAfter the fix each scoped document equals its own environment's producer output, and
the two documents differ from each other.
The blast radius is wider than the card described
The builder derives the whole document from its own kernel, so this was never a
two-capability defect. Host-derived fields beyond the two the card named:
servicesmap, from that kernel's service registry;locale—default,supported,timezone— from that kernel'si18noccupant;transactionalBatchandsearch;routeskeys this handler does not itself overwrite:analytics,automation,ai,i18n,notifications,realtime,storage.One card claim is falsified:
versionnever followed the wrong protocol — thehandler overwrites it from server config on every request.
The unscoped route: fork evaluated, did not fire
Triage set this as fork-to-report. It was measured rather than reasoned, and no fork
arises. The unscoped route reaches the same shared resolution
(
resolveRequestEnvironmentId, ADR-0076 D11 step 4) instead of getting a special case:resolveProtocolfalls through to the captured protocol. Measured unchanged.describes the kernel that actually serves the data.
/discoveryand the data routes beside it describe one kernel.What changed
The protocol acquisition only. The
realBaseroute-string substitution and the trailingscopingblock already readreq.params.environmentIdand are untouched — pinned, andindependently confirmed by the reverse verification below. The placeholder normalisation
is
probeMcpServeable's, for its reason (#9120): an unsubstituted route pattern is theabsence of an id, not an id.
Tests
packages/rest/src/discovery-per-request-protocol.test.ts— 8 pins, real producers onboth sides of every comparison. Each carries its negative half (the served body must not
equal the host's), because a host that happened to match would make the positive half
pass vacuously; the three protocols in the harness differ from each other for exactly
that reason. Capabilities are compared on their
enabledbits, since the handlerlegitimately overlays the
transactionalBatchandsearchdescriptors — a separatecontract with its own pins.
Reverse verification, from the committed state: reverting only the protocol
acquisition turns 5 red / 3 green. The three that stay green are exactly the ones
asserting behaviour the unfixed code already had —
realBasesubstitution,scoping,and the placeholder guard — which is what confirms the fix is confined to the acquisition.
One existing pin needed its double widened, and it is reported rather than quietly
adjusted. The two end-to-end drives in the #9120 block used a per-request kernel double
that resolved every service name to one value; now that
/discoveryalso resolvesprotocoloff that same kernel, it handed the mcp stub back forprotocol. A realkernel serves both slots. The #9120 assertions are byte-identical — only the double
became faithful.
The
@objectstack/coresuite was run for theHttpTestAdapterread-coupling landedhours ago: 857 tests, no pin moved. Writing those pins against whatever
routes.dataadvertises rather than the literal path did survive this card.
Verification
All of the following at
b48ea0e4d, the final commit:@objectstack/rest— 123 files, 2030 tests, pass;tsc --noEmitclean@objectstack/core— 35 files, 857 tests, passscripts/pm/dispatch-gates.mjs, which added achangeset family and the whole test-file convention family beyond the dispatched list:
check:authz-resolver,check:route-envelope,check:dispatcher-error-vocabulary,check:cross-package-test-inputs,check:changeset-gate-self-tests,check:objectui-changeset,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:nul-bytes,check:slot-lookup,check:verify-stand-in,check:type-check-coverage,check:type-check-debt,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-affected-docs— all greencheck:type-check-debtwas run on the built workspace closure, as lint.yml does —33 ledger entries re-measured in 210s, none above its recorded number
pnpm lint(theESLintjob's own step) clean, plus that job's ratchet family aboveNo gate was weakened, rebaselined or skipped.
Generated by Claude Code