diff --git a/.changeset/runtime-dispatcher-discovery-envelope.md b/.changeset/runtime-dispatcher-discovery-envelope.md
new file mode 100644
index 0000000000..574b2493d8
--- /dev/null
+++ b/.changeset/runtime-dispatcher-discovery-envelope.md
@@ -0,0 +1,28 @@
+---
+"@objectstack/runtime": minor
+---
+
+feat(runtime): the dispatcher's two discovery bodies join the response envelope (#9813)
+
+
+
+`GET /.well-known/objectstack` and the REST-less fallback `GET {prefix}/discovery`
+answered `{ data: {discovery} }` with no `success` flag — one key short of the
+declared `BaseResponseSchema` envelope. They now answer
+`{ success: true, data: {discovery} }`.
+
+This inherits the #9436 maintainer ruling (2026-08-18, option A) on the hono
+adapter's identical discovery bodies, with its reason intact: machine-read
+discovery surfaces — SDK `connect()` fallback probes, codegen, AI clients — are
+the envelope's core constituency, and the migration is one additive key. It is
+deliberately not #9389's pre-auth exemption, which is a closed list of SPA-read
+shell-bootstrap surfaces these bodies are not on. Readers that unwrapped
+`body.data` keep working unchanged; envelope-aware readers that discriminate on
+`success` now unwrap these routes correctly.
diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx
index 13a54b5098..8bfba0d722 100644
--- a/content/docs/api/index.mdx
+++ b/content/docs/api/index.mdx
@@ -151,7 +151,7 @@ send an atomic batch or fall back to client-side sequencing, instead of probing
### `GET /.well-known/objectstack`
-Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`, `features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does not. The client SDK's `connect()` tries `/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or the bare `body`.
+Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its body is wrapped in the response envelope as `{ "success": true, "data": { ... } }` and includes fields (`name`, `environment`, `features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does not. The client SDK's `connect()` tries `/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or the bare `body`.
**Service Status Values**: `available` (fully operational), `registered` (route declared but handler unverified — may return 501), `degraded` (partial functionality), `unavailable` (not installed), `stub` (placeholder that throws errors)
diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx
index bf75732cdb..cd299fc01a 100644
--- a/content/docs/protocol/kernel/http-protocol.mdx
+++ b/content/docs/protocol/kernel/http-protocol.mdx
@@ -107,9 +107,9 @@ descriptor, each derived from what is actually registered — never hardcoded. S
### `GET /.well-known/objectstack`
Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its
-body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`,
-`features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does
-not. This path is unconditionally dispatcher-owned: no other plugin registers it, so it
+body is wrapped in the response envelope as `{ "success": true, "data": { ... } }` and
+includes fields (`name`, `environment`, `features`, `locale`) that the
+`@objectstack/rest`-served `/api/v1` response above does not. This path is unconditionally dispatcher-owned: no other plugin registers it, so it
answers with this shape whether or not REST is mounted. The client SDK's `connect()` tries
`/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or
the bare `body`.
@@ -123,6 +123,7 @@ Host: api.acme.com
**Response:**
```json
{
+ "success": true,
"data": {
"name": "ObjectOS",
"version": "1.0.0",
@@ -225,8 +226,8 @@ dispatcher owns `/api/v1/discovery` as the fallback registrant, so that path and
`/api/v1` is registered by `@objectstack/rest` alone and is not served at all). As soon as
`@objectstack/rest` is mounted it takes `/api/v1/discovery` under the single-owner rule
(ADR-0076 D11) and the two paths answer different *documents* — same schema, different
-producers, so the envelope (`{ "data": … }` here, bare there) and the values differ even
-though the key set no longer does.
+producers, so the envelope (`{ "success": true, "data": … }` here, bare there) and the
+values differ even though the key set no longer does.
**Why discovery matters:**
diff --git a/packages/runtime/src/dispatcher-plugin.routes.test.ts b/packages/runtime/src/dispatcher-plugin.routes.test.ts
index 9bbe136b32..80a22a46e2 100644
--- a/packages/runtime/src/dispatcher-plugin.routes.test.ts
+++ b/packages/runtime/src/dispatcher-plugin.routes.test.ts
@@ -245,6 +245,35 @@ describe('createDispatcherPlugin — HTTP route registration', () => {
}
});
+ // #9813 (inheriting the #9436 maintainer ruling, 2026-08-18, option A):
+ // machine-read discovery bodies answer the declared envelope. Both bodies
+ // used to be a bare `{ data }` — one key short of BaseResponseSchema — which
+ // objectui's envelope-discriminating readers (`typeof body.success ===
+ // 'boolean' && 'data' in body`) mis-parsed. The pin is the exact top-level
+ // key set, not just `success`: an extra sibling key would be the strayKeys
+ // dialect arriving back.
+ it('envelopes both discovery bodies as { success: true, data }', async () => {
+ const { server, handlers } = makeFakeServer();
+ const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
+ await plugin.start?.(makeCtx(server));
+
+ for (const route of ['GET /.well-known/objectstack', 'GET /api/v1/discovery']) {
+ const handler = handlers[route];
+ expect(handler, `${route} should be registered`).toBeTypeOf('function');
+ let body: any;
+ const res: any = {
+ header: () => {},
+ json: (b: unknown) => { body = b; },
+ };
+ await handler({}, res);
+ expect(body?.success, `${route} success flag`).toBe(true);
+ expect(body?.data, `${route} data payload`).toBeDefined();
+ // The payload stays under `data` — the flip was additive, nothing moved.
+ expect(body.data.routes, `${route} discovery document under data`).toBeDefined();
+ expect(Object.keys(body).sort(), `${route} top-level keys`).toEqual(['data', 'success']);
+ }
+ });
+
// ADR-0076 D11 / OQ#9 — single owner for ${prefix}/discovery. When the REST
// plugin is registered on the same kernel it serves /api/v1/discovery itself;
// which payload a client saw used to depend on plugin start order
diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts
index d0b507ae62..52932bfd4a 100644
--- a/packages/runtime/src/dispatcher-plugin.ts
+++ b/packages/runtime/src/dispatcher-plugin.ts
@@ -772,7 +772,14 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
// enabled" against a live server (cloud#152). The body is computed
// fresh per request; the only staleness is the HTTP cache layer.
res.header('Cache-Control', 'no-store');
- res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
+ // Enveloped (`{ success: true, data }`) under the #9436 maintainer
+ // ruling (2026-08-18, option A), inherited by #9813 — machine-read
+ // discovery bodies are the envelope's core constituency and the
+ // migration is one additive key. Deliberately NOT #9389's pre-auth
+ // exemption: that is a closed list of SPA-read surfaces, and this
+ // body is read by SDKs (`connect()`'s fallback probe), codegen and
+ // AI clients. Every measured reader tolerates the added key.
+ res.json({ success: true, data: await dispatcher.getDiscoveryInfo(prefix) });
});
// ── Discovery (versioned API path) ──────────────────────────
@@ -796,9 +803,10 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
}
}
// See the .well-known handler above: discovery must not be cached
- // (mutable runtime config; cloud#152 stale `routes.mcp`).
+ // (mutable runtime config; cloud#152 stale `routes.mcp`), and the
+ // body is enveloped under the same #9436 ruling (via #9813).
res.header('Cache-Control', 'no-store');
- res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
+ res.json({ success: true, data: await dispatcher.getDiscoveryInfo(prefix) });
});
} else {
ctx.logger.info(`[Dispatcher] ${prefix}/discovery ceded to com.objectstack.rest.api (single owner)`);
diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs
index 8b60232907..6eeec6e736 100644
--- a/scripts/check-route-envelope.mjs
+++ b/scripts/check-route-envelope.mjs
@@ -681,6 +681,45 @@ const PLUGIN_ROUTE_MODULES = {
},
};
+const EXPRESS_RESPONSE_RECEIVERS = new Set(['res']);
+
+/**
+ * Surface 4 (#9813): modules that write express-style `res.json(…)` responses
+ * on an `IHttpServer` — the dialect none of the other three surfaces can see.
+ * `dispatcher-plugin.ts` served the exact `{ data }` discovery shape #9436 was
+ * ruled on for a day with no counter anywhere, because it writes through `res`
+ * rather than a Hono context and returns nothing to a central sender.
+ *
+ * ⚠ ENUMERATED, not discovered. The other populations refuse an undeclared
+ * response-writing module; this one audits only the files named here, because
+ * a discovery walk for this dialect first needs a read/write discriminator —
+ * fetch's `Response.json()` is a zero-argument READ on the same receiver name
+ * (`const res = await fetch(…); await res.json()`), and 20 non-test files
+ * under packages/ carry the `res.json(` spelling today, most of them fetch
+ * readers. Growing the walk is #9937; adding a NEW express-style route module
+ * to this table is part of adding the module.
+ *
+ * Entries carry the same counters, `ratchet`/`exempt`/`note` grammar and
+ * audit (`auditPluginRouteModule`) as PLUGIN_ROUTE_MODULES — one grammar, two
+ * receiver dialects.
+ */
+const IHTTP_ROUTE_MODULES = {
+ // Ten bodies. The two discovery bodies (`/.well-known/objectstack`,
+ // unconditional, and the REST-less `${prefix}/discovery` fallback) were
+ // enveloped by #9813 under the #9436 maintainer ruling (2026-08-18, option
+ // A, inherited with its reason intact: machine-read discovery bodies are
+ // the envelope's core constituency and the migration is one additive key —
+ // deliberately NOT #9389's pre-auth exemption, whose closed SPA-read list
+ // these sites are not on). The two `{ success: false, error: buildApiError(…) }`
+ // exits are conformant, and five relayed bodies (`result.body`,
+ // `ANONYMOUS_DENY_BODY`, …) are deliberately invisible, as everywhere.
+ 'packages/runtime/src/dispatcher-plugin.ts': {
+ unenveloped: 1,
+ ratchet: '#9936 (envelope or rule on the SSE-fallback `{ events }` body)',
+ note: 'the streaming branch\'s JSON fallback — a transport whose `res` cannot stream gets the collected events as a bare `{ events }`, no `success` flag and the payload beside the envelope rather than under `data`. A different consumer population from the discovery bodies (callers that asked for an SSE stream), so #9813\'s inherited ruling does not reach it; #9936 carries the fork',
+ },
+};
+
/**
* Count the ways one plugin-route module's hand-built Hono bodies depart from
* the declared envelope.
@@ -688,10 +727,17 @@ const PLUGIN_ROUTE_MODULES = {
* Only `.json(, …)` is judged — see the header on why
* relayed bodies are deliberately invisible here.
*
+ * `receivers` names the identifiers that count as a response receiver. The
+ * default is the Hono pair; passing `EXPRESS_RESPONSE_RECEIVERS` reads the
+ * express-style `res.json(…)` dialect instead (#9813) — same body grammar,
+ * different receiver, so the counters and their meanings are shared.
+ *
* @param {string} source TypeScript source text.
+ * @param {string} fileName reported in sites.
+ * @param {Set} receivers identifiers judged as response receivers.
* @returns {{bodies: number, unenveloped: number, errorWithoutMessage: number, errorCodeNotString: number, strayKeys: number, stringError: number, siblingCode: number, sites: Record}}
*/
-export function scanHonoRouteSource(source, fileName = 'plugin.ts') {
+export function scanHonoRouteSource(source, fileName = 'plugin.ts', receivers = HONO_CONTEXT_RECEIVERS) {
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
const found = {
bodies: 0,
@@ -711,7 +757,7 @@ export function scanHonoRouteSource(source, fileName = 'plugin.ts') {
ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.text === 'json' &&
ts.isIdentifier(node.expression.expression) &&
- HONO_CONTEXT_RECEIVERS.has(node.expression.expression.text)
+ receivers.has(node.expression.expression.text)
) {
found.bodies += 1;
const arg = node.arguments[0];
@@ -1397,6 +1443,31 @@ function audit() {
}
}
+ // ── The IHttpServer express-style modules (#9813) — enumerated, see table ──
+ const ihttpScans = {};
+ for (const [file, declared] of Object.entries(IHTTP_ROUTE_MODULES)) {
+ let source;
+ try {
+ source = readFileSync(join(ROOT, file), 'utf8');
+ } catch {
+ problems.push(
+ `${file}\n declared in IHTTP_ROUTE_MODULES but not found — moved or deleted?\n` +
+ ` Update the table.`,
+ );
+ continue;
+ }
+ const got = scanHonoRouteSource(source, file, EXPRESS_RESPONSE_RECEIVERS);
+ if (got.bodies === 0) {
+ problems.push(
+ `${file}\n declared in IHTTP_ROUTE_MODULES but no longer writes an express-style\n` +
+ ` response (\`res.json(…)\`) — moved, deleted, or converted? Update the table.`,
+ );
+ continue;
+ }
+ ihttpScans[file] = got;
+ problems.push(...auditPluginRouteModule(file, declared, got));
+ }
+
if (problems.length) {
console.error('✗ Route-envelope conformance (#3843)\n');
for (const p of problems) console.error(' ' + p + '\n');
@@ -1468,6 +1539,24 @@ function audit() {
for (const [file, m] of pExempt) {
console.log(` – exempt, closed at ${pCounts(m)}: ${file} — ${m.exempt}`);
}
+
+ const iEntries = Object.entries(IHTTP_ROUTE_MODULES);
+ const iRatcheted = iEntries.filter(([, m]) => m.ratchet);
+ const iExempt = iEntries.filter(([, m]) => m.exempt);
+ const iBodies = Object.values(ihttpScans).reduce((n, s) => n + s.bodies, 0);
+ console.log(
+ `✓ IHttpServer express-style modules — ${iEntries.length} module(s) audited ` +
+ `(ENUMERATED, not discovered — the walk is #9937), ` +
+ `${iBodies} hand-built body/bodies (count reported, NOT pinned): ` +
+ `${iEntries.length - iRatcheted.length - iExempt.length} conformant, ` +
+ `${iRatcheted.length} ratcheted, ${iExempt.length} exempt`,
+ );
+ for (const [file, m] of iRatcheted) {
+ console.log(` ⚠ ratchet ${m.ratchet}: ${file} (${pCounts(m)}; ticks down only) — ${m.note}`);
+ }
+ for (const [file, m] of iExempt) {
+ console.log(` – exempt, closed at ${pCounts(m)}: ${file} — ${m.exempt}`);
+ }
}
// ── Self-test ────────────────────────────────────────────────────────────────
@@ -1712,6 +1801,34 @@ function selfTest() {
p = scanHonoRouteSource(`res.status(404).json({ error: 'nope' });`);
assert(p.bodies === 0, `a res.json write must not enter surface 3 → ${JSON.stringify(p)}`);
+ // ── Surface 4 (#9813): the same grammar under express receivers ───────────
+ //
+ // The receiver set is the load-bearing difference: identical source reads as
+ // zero bodies under the default (Hono) receivers and as a bare body under
+ // EXPRESS_RESPONSE_RECEIVERS — which is exactly how dispatcher-plugin.ts's
+ // discovery bodies sat invisible beside a green surface 3.
+ const expressBare = `res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });`;
+ p = scanHonoRouteSource(expressBare, 'x.ts', EXPRESS_RESPONSE_RECEIVERS);
+ assert(
+ p.bodies === 1 && p.unenveloped === 1,
+ `a bare express body must count under express receivers → ${JSON.stringify(p)}`,
+ );
+ assert(
+ scanHonoRouteSource(expressBare, 'x.ts').bodies === 0,
+ 'the same express body must stay invisible to the default (Hono) receivers',
+ );
+ p = scanHonoRouteSource(`res.json({ success: true, data: await x() });`, 'x.ts', EXPRESS_RESPONSE_RECEIVERS);
+ assert(
+ p.bodies === 1 && p.unenveloped === 0,
+ `the enveloped flip must read conformant under express receivers → ${JSON.stringify(p)}`,
+ );
+ // Relayed bodies are counted but never judged, in this dialect like the others.
+ p = scanHonoRouteSource(`res.json(result.body); res.json(ANONYMOUS_DENY_BODY);`, 'x.ts', EXPRESS_RESPONSE_RECEIVERS);
+ assert(
+ p.bodies === 2 && p.unenveloped === 0,
+ `relayed express bodies must count as bodies, never as counters → ${JSON.stringify(p)}`,
+ );
+
// ── The #9389 ruling: an exemption is a CLOSED list ───────────────────────
//
// The ruling's own load-bearing clause is that the boundary stays enumerated: