Skip to content

fix(rest): /discovery describes the request's environment, not the control plane - #9412

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-9292-discovery-per-request-protocol
Aug 18, 2026
Merged

fix(rest): /discovery describes the request's environment, not the control plane#9412
os-zhuang merged 3 commits into
mainfrom
claude/issue-9292-discovery-per-request-protocol

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#9292

registerDiscoveryEndpoints' handler opened with this.protocol.getDiscovery() — the
control-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. /discovery is 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,
services and locale, and both carried the host's answers rather than either
environment's. For the richer tenant:

fieldenvironment really isdocument served
capabilities (13 keys)transactionalBatchautomationcronexportcommentsanalyticsaii18n all trueevery one of them false
capabilities.notificationsfalsetrue (the host's service, deliverable by no tenant)
servicesmetadata data analytics automation job ai i18nmetadata data notification
localezh-CN, supported zh-CN,enen, supported en
routesplus analyticsautomationaii18nthose four missing, phantom notifications present

After 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:

  • the entire services map, from that kernel's service registry;
  • localedefault, supported, timezone — from that kernel's i18n occupant;
  • eleven further capability keys, not just transactionalBatch and search;
  • the routes keys this handler does not itself overwrite: analytics, automation,
    ai, i18n, notifications, realtime, storage.

One card claim is falsified:version never followed the wrong protocol — the
handler 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:

  • control-plane boot — nothing resolves an environment, the chain returns undefined,
    resolveProtocol falls through to the captured protocol. Measured unchanged.
  • single-environment boot — step 3, the default provider, so the document now
    describes the kernel that actually serves the data.
  • hostname-routed multi-tenant host — the same authority the HTTP dispatcher uses, so
    /discovery and the data routes beside it describe one kernel.

What changed

The protocol acquisition only. The realBase route-string substitution and the trailing
scoping block already read req.params.environmentId and are untouched — pinned, and
independently confirmed by the reverse verification below. The placeholder normalisation
is probeMcpServeable's, for its reason (#9120): an unsubstituted route pattern is the
absence of an id, not an id.

Tests

packages/rest/src/discovery-per-request-protocol.test.ts — 8 pins, real producers on
both 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 enabled bits, since the handler
legitimately overlays the transactionalBatch and search descriptors — a separate
contract 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 — realBase substitution, 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 /discovery also resolves
protocol off that same kernel, it handed the mcp stub back for protocol. A real
kernel serves both slots. The #9120 assertions are byte-identical — only the double
became faithful.

The @objectstack/core suite was run for the HttpTestAdapter read-coupling landed
hours ago: 857 tests, no pin moved. Writing those pins against whatever routes.data
advertises 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 --noEmit clean
  • @objectstack/core — 35 files, 857 tests, pass
  • gates re-derived on the actual diff with scripts/pm/dispatch-gates.mjs, which added a
    changeset 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 green
  • check:type-check-debt was run on the built workspace closure, as lint.yml does —
    33 ledger entries re-measured in 210s, none above its recorded number
  • pnpm lint (the ESLint job's own step) clean, plus that job's ratchet family above

No gate was weakened, rebaselined or skipped.


Generated by Claude Code

…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>
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 1 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via registerDiscoveryEndpoints (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see

Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json origin/mainpackageMentionDocs.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RestServer's /discovery handler builds its document from the control-plane protocol, not the request's — scoped /environments/:id/discovery included

2 participants

@os-zhuang@claude