Skip to content

Commit f78a83e

Browse files
committed
feat(runtime): envelope the dispatcher's two discovery bodies (#9813)
Inherits the #9436 maintainer ruling (2026-08-18, option A) with its reason intact: machine-read discovery bodies are the envelope's core constituency and the migration is one additive key. Adds success: true to both GET /.well-known/objectstack and the REST-less GET {prefix}/discovery fallback, pins the shape in dispatcher-plugin.routes.test.ts, updates the two documenting pages, and grows check-route-envelope.mjs an enumerated fourth scan population for express-style res.json writes on an IHttpServer (dispatcher-plugin.ts: unenveloped 1, ratcheted to #9936; the discovery walk for the dialect is #9937). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WeN7F6jQFpcqW2BN56RdPa
1 parent 45862a5 commit f78a83e

6 files changed

Lines changed: 194 additions & 11 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): the dispatcher's two discovery bodies join the response envelope (#9813)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) Additive wire change:
8+
`GET /.well-known/objectstack` (always dispatcher-owned) and the REST-less
9+
fallback `GET {prefix}/discovery` gain `success: true` beside the existing
10+
`data` key — nothing authorable and no key is renamed, retired or removed, so
11+
there is no conversion to register. Every measured reader (`@objectstack/client`
12+
`connect()`'s `body.data || body`, the QA http-adapter's `'routes' in body`
13+
discriminator, objectui's `typeof body.success === 'boolean' && 'data' in body`
14+
unwrap) resolves the new shape to the same document. -->
15+
16+
`GET /.well-known/objectstack` and the REST-less fallback `GET {prefix}/discovery`
17+
answered `{ data: {discovery} }` with no `success` flag — one key short of the
18+
declared `BaseResponseSchema` envelope. They now answer
19+
`{ success: true, data: {discovery} }`.
20+
21+
This inherits the #9436 maintainer ruling (2026-08-18, option A) on the hono
22+
adapter's identical discovery bodies, with its reason intact: machine-read
23+
discovery surfaces — SDK `connect()` fallback probes, codegen, AI clients — are
24+
the envelope's core constituency, and the migration is one additive key. It is
25+
deliberately not #9389's pre-auth exemption, which is a closed list of SPA-read
26+
shell-bootstrap surfaces these bodies are not on. Readers that unwrapped
27+
`body.data` keep working unchanged; envelope-aware readers that discriminate on
28+
`success` now unwrap these routes correctly.

‎content/docs/api/index.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ send an atomic batch or fall back to client-side sequencing, instead of probing
151151

152152
### `GET /.well-known/objectstack`
153153

154-
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`.
154+
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`.
155155

156156
<Callout type="info">
157157
**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)

‎content/docs/protocol/kernel/http-protocol.mdx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,9 @@ descriptor, each derived from what is actually registered — never hardcoded. S
107107
### `GET /.well-known/objectstack`
108108

109109
Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its
110-
body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`,
111-
`features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does
112-
not. This path is unconditionally dispatcher-owned: no other plugin registers it, so it
110+
body is wrapped in the response envelope as `{ "success": true, "data": { ... } }` and
111+
includes fields (`name`, `environment`, `features`, `locale`) that the
112+
`@objectstack/rest`-served `/api/v1` response above does not. This path is unconditionally dispatcher-owned: no other plugin registers it, so it
113113
answers with this shape whether or not REST is mounted. The client SDK's `connect()` tries
114114
`/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or
115115
the bare `body`.
@@ -123,6 +123,7 @@ Host: api.acme.com
123123
**Response:**
124124
```json
125125
{
126+
"success": true,
126127
"data": {
127128
"name": "ObjectOS",
128129
"version": "1.0.0",
@@ -225,8 +226,8 @@ dispatcher owns `/api/v1/discovery` as the fallback registrant, so that path and
225226
`/api/v1` is registered by `@objectstack/rest` alone and is not served at all). As soon as
226227
`@objectstack/rest` is mounted it takes `/api/v1/discovery` under the single-owner rule
227228
(ADR-0076 D11) and the two paths answer different *documents* — same schema, different
228-
producers, so the envelope (`{ "data": … }` here, bare there) and the values differ even
229-
though the key set no longer does.
229+
producers, so the envelope (`{ "success": true, "data": … }` here, bare there) and the
230+
values differ even though the key set no longer does.
230231
</Callout>
231232

232233
**Why discovery matters:**

‎packages/runtime/src/dispatcher-plugin.routes.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,35 @@ describe('createDispatcherPlugin — HTTP route registration', () => {
245245
}
246246
});
247247

248+
// #9813 (inheriting the #9436 maintainer ruling, 2026-08-18, option A):
249+
// machine-read discovery bodies answer the declared envelope. Both bodies
250+
// used to be a bare `{ data }` — one key short of BaseResponseSchema — which
251+
// objectui's envelope-discriminating readers (`typeof body.success ===
252+
// 'boolean' && 'data' in body`) mis-parsed. The pin is the exact top-level
253+
// key set, not just `success`: an extra sibling key would be the strayKeys
254+
// dialect arriving back.
255+
it('envelopes both discovery bodies as { success: true, data }', async () => {
256+
const { server, handlers } = makeFakeServer();
257+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
258+
await plugin.start?.(makeCtx(server));
259+
260+
for (const route of ['GET /.well-known/objectstack', 'GET /api/v1/discovery']) {
261+
const handler = handlers[route];
262+
expect(handler, `${route} should be registered`).toBeTypeOf('function');
263+
let body: any;
264+
const res: any = {
265+
header: () => {},
266+
json: (b: unknown) => { body = b; },
267+
};
268+
await handler({}, res);
269+
expect(body?.success, `${route} success flag`).toBe(true);
270+
expect(body?.data, `${route} data payload`).toBeDefined();
271+
// The payload stays under `data` — the flip was additive, nothing moved.
272+
expect(body.data.routes, `${route} discovery document under data`).toBeDefined();
273+
expect(Object.keys(body).sort(), `${route} top-level keys`).toEqual(['data', 'success']);
274+
}
275+
});
276+
248277
// ADR-0076 D11 / OQ#9 — single owner for ${prefix}/discovery. When the REST
249278
// plugin is registered on the same kernel it serves /api/v1/discovery itself;
250279
// which payload a client saw used to depend on plugin start order

‎packages/runtime/src/dispatcher-plugin.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -772,7 +772,14 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
772772
// enabled" against a live server (cloud#152). The body is computed
773773
// fresh per request; the only staleness is the HTTP cache layer.
774774
res.header('Cache-Control', 'no-store');
775-
res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
775+
// Enveloped (`{ success: true, data }`) under the #9436 maintainer
776+
// ruling (2026-08-18, option A), inherited by #9813 — machine-read
777+
// discovery bodies are the envelope's core constituency and the
778+
// migration is one additive key. Deliberately NOT #9389's pre-auth
779+
// exemption: that is a closed list of SPA-read surfaces, and this
780+
// body is read by SDKs (`connect()`'s fallback probe), codegen and
781+
// AI clients. Every measured reader tolerates the added key.
782+
res.json({ success: true, data: await dispatcher.getDiscoveryInfo(prefix) });
776783
});
777784

778785
// ── Discovery (versioned API path) ──────────────────────────
@@ -796,9 +803,10 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
796803
}
797804
}
798805
// See the .well-known handler above: discovery must not be cached
799-
// (mutable runtime config; cloud#152 stale `routes.mcp`).
806+
// (mutable runtime config; cloud#152 stale `routes.mcp`), and the
807+
// body is enveloped under the same #9436 ruling (via #9813).
800808
res.header('Cache-Control', 'no-store');
801-
res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
809+
res.json({ success: true, data: await dispatcher.getDiscoveryInfo(prefix) });
802810
});
803811
} else {
804812
ctx.logger.info(`[Dispatcher] ${prefix}/discovery ceded to com.objectstack.rest.api (single owner)`);

‎scripts/check-route-envelope.mjs‎

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -681,17 +681,63 @@ const PLUGIN_ROUTE_MODULES = {
681681
},
682682
};
683683

684+
const EXPRESS_RESPONSE_RECEIVERS = new Set(['res']);
685+
686+
/**
687+
* Surface 4 (#9813): modules that write express-style `res.json(…)` responses
688+
* on an `IHttpServer` — the dialect none of the other three surfaces can see.
689+
* `dispatcher-plugin.ts` served the exact `{ data }` discovery shape #9436 was
690+
* ruled on for a day with no counter anywhere, because it writes through `res`
691+
* rather than a Hono context and returns nothing to a central sender.
692+
*
693+
* ⚠ ENUMERATED, not discovered. The other populations refuse an undeclared
694+
* response-writing module; this one audits only the files named here, because
695+
* a discovery walk for this dialect first needs a read/write discriminator —
696+
* fetch's `Response.json()` is a zero-argument READ on the same receiver name
697+
* (`const res = await fetch(…); await res.json()`), and 20 non-test files
698+
* under packages/ carry the `res.json(` spelling today, most of them fetch
699+
* readers. Growing the walk is #9937; adding a NEW express-style route module
700+
* to this table is part of adding the module.
701+
*
702+
* Entries carry the same counters, `ratchet`/`exempt`/`note` grammar and
703+
* audit (`auditPluginRouteModule`) as PLUGIN_ROUTE_MODULES — one grammar, two
704+
* receiver dialects.
705+
*/
706+
const IHTTP_ROUTE_MODULES = {
707+
// Ten bodies. The two discovery bodies (`/.well-known/objectstack`,
708+
// unconditional, and the REST-less `${prefix}/discovery` fallback) were
709+
// enveloped by #9813 under the #9436 maintainer ruling (2026-08-18, option
710+
// A, inherited with its reason intact: machine-read discovery bodies are
711+
// the envelope's core constituency and the migration is one additive key —
712+
// deliberately NOT #9389's pre-auth exemption, whose closed SPA-read list
713+
// these sites are not on). The two `{ success: false, error: buildApiError(…) }`
714+
// exits are conformant, and five relayed bodies (`result.body`,
715+
// `ANONYMOUS_DENY_BODY`, …) are deliberately invisible, as everywhere.
716+
'packages/runtime/src/dispatcher-plugin.ts': {
717+
unenveloped: 1,
718+
ratchet: '#9936 (envelope or rule on the SSE-fallback `{ events }` body)',
719+
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',
720+
},
721+
};
722+
684723
/**
685724
* Count the ways one plugin-route module's hand-built Hono bodies depart from
686725
* the declared envelope.
687726
*
688727
* Only `<ctx>.json(<objectLiteral>, …)` is judged — see the header on why
689728
* relayed bodies are deliberately invisible here.
690729
*
730+
* `receivers` names the identifiers that count as a response receiver. The
731+
* default is the Hono pair; passing `EXPRESS_RESPONSE_RECEIVERS` reads the
732+
* express-style `res.json(…)` dialect instead (#9813) — same body grammar,
733+
* different receiver, so the counters and their meanings are shared.
734+
*
691735
* @param {string} source TypeScript source text.
736+
* @param {string} fileName reported in sites.
737+
* @param {Set<string>} receivers identifiers judged as response receivers.
692738
* @returns {{bodies: number, unenveloped: number, errorWithoutMessage: number, errorCodeNotString: number, strayKeys: number, stringError: number, siblingCode: number, sites: Record<string, string[]>}}
693739
*/
694-
export function scanHonoRouteSource(source, fileName = 'plugin.ts') {
740+
export function scanHonoRouteSource(source, fileName = 'plugin.ts', receivers = HONO_CONTEXT_RECEIVERS) {
695741
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
696742
const found = {
697743
bodies: 0,
@@ -711,7 +757,7 @@ export function scanHonoRouteSource(source, fileName = 'plugin.ts') {
711757
ts.isPropertyAccessExpression(node.expression) &&
712758
node.expression.name.text === 'json' &&
713759
ts.isIdentifier(node.expression.expression) &&
714-
HONO_CONTEXT_RECEIVERS.has(node.expression.expression.text)
760+
receivers.has(node.expression.expression.text)
715761
) {
716762
found.bodies += 1;
717763
const arg = node.arguments[0];
@@ -1397,6 +1443,31 @@ function audit() {
13971443
}
13981444
}
13991445

1446+
// ── The IHttpServer express-style modules (#9813) — enumerated, see table ──
1447+
const ihttpScans = {};
1448+
for (const [file, declared] of Object.entries(IHTTP_ROUTE_MODULES)) {
1449+
let source;
1450+
try {
1451+
source = readFileSync(join(ROOT, file), 'utf8');
1452+
} catch {
1453+
problems.push(
1454+
`${file}\n declared in IHTTP_ROUTE_MODULES but not found — moved or deleted?\n` +
1455+
` Update the table.`,
1456+
);
1457+
continue;
1458+
}
1459+
const got = scanHonoRouteSource(source, file, EXPRESS_RESPONSE_RECEIVERS);
1460+
if (got.bodies === 0) {
1461+
problems.push(
1462+
`${file}\n declared in IHTTP_ROUTE_MODULES but no longer writes an express-style\n` +
1463+
` response (\`res.json(…)\`) — moved, deleted, or converted? Update the table.`,
1464+
);
1465+
continue;
1466+
}
1467+
ihttpScans[file] = got;
1468+
problems.push(...auditPluginRouteModule(file, declared, got));
1469+
}
1470+
14001471
if (problems.length) {
14011472
console.error('✗ Route-envelope conformance (#3843)\n');
14021473
for (const p of problems) console.error(' ' + p + '\n');
@@ -1468,6 +1539,24 @@ function audit() {
14681539
for (const [file, m] of pExempt) {
14691540
console.log(` – exempt, closed at ${pCounts(m)}: ${file} — ${m.exempt}`);
14701541
}
1542+
1543+
const iEntries = Object.entries(IHTTP_ROUTE_MODULES);
1544+
const iRatcheted = iEntries.filter(([, m]) => m.ratchet);
1545+
const iExempt = iEntries.filter(([, m]) => m.exempt);
1546+
const iBodies = Object.values(ihttpScans).reduce((n, s) => n + s.bodies, 0);
1547+
console.log(
1548+
`✓ IHttpServer express-style modules — ${iEntries.length} module(s) audited ` +
1549+
`(ENUMERATED, not discovered — the walk is #9937), ` +
1550+
`${iBodies} hand-built body/bodies (count reported, NOT pinned): ` +
1551+
`${iEntries.length - iRatcheted.length - iExempt.length} conformant, ` +
1552+
`${iRatcheted.length} ratcheted, ${iExempt.length} exempt`,
1553+
);
1554+
for (const [file, m] of iRatcheted) {
1555+
console.log(` ⚠ ratchet ${m.ratchet}: ${file} (${pCounts(m)}; ticks down only) — ${m.note}`);
1556+
}
1557+
for (const [file, m] of iExempt) {
1558+
console.log(` – exempt, closed at ${pCounts(m)}: ${file} — ${m.exempt}`);
1559+
}
14711560
}
14721561

14731562
// ── Self-test ────────────────────────────────────────────────────────────────
@@ -1712,6 +1801,34 @@ function selfTest() {
17121801
p = scanHonoRouteSource(`res.status(404).json({ error: 'nope' });`);
17131802
assert(p.bodies === 0, `a res.json write must not enter surface 3 → ${JSON.stringify(p)}`);
17141803

1804+
// ── Surface 4 (#9813): the same grammar under express receivers ───────────
1805+
//
1806+
// The receiver set is the load-bearing difference: identical source reads as
1807+
// zero bodies under the default (Hono) receivers and as a bare body under
1808+
// EXPRESS_RESPONSE_RECEIVERS — which is exactly how dispatcher-plugin.ts's
1809+
// discovery bodies sat invisible beside a green surface 3.
1810+
const expressBare = `res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });`;
1811+
p = scanHonoRouteSource(expressBare, 'x.ts', EXPRESS_RESPONSE_RECEIVERS);
1812+
assert(
1813+
p.bodies === 1 && p.unenveloped === 1,
1814+
`a bare express body must count under express receivers → ${JSON.stringify(p)}`,
1815+
);
1816+
assert(
1817+
scanHonoRouteSource(expressBare, 'x.ts').bodies === 0,
1818+
'the same express body must stay invisible to the default (Hono) receivers',
1819+
);
1820+
p = scanHonoRouteSource(`res.json({ success: true, data: await x() });`, 'x.ts', EXPRESS_RESPONSE_RECEIVERS);
1821+
assert(
1822+
p.bodies === 1 && p.unenveloped === 0,
1823+
`the enveloped flip must read conformant under express receivers → ${JSON.stringify(p)}`,
1824+
);
1825+
// Relayed bodies are counted but never judged, in this dialect like the others.
1826+
p = scanHonoRouteSource(`res.json(result.body); res.json(ANONYMOUS_DENY_BODY);`, 'x.ts', EXPRESS_RESPONSE_RECEIVERS);
1827+
assert(
1828+
p.bodies === 2 && p.unenveloped === 0,
1829+
`relayed express bodies must count as bodies, never as counters → ${JSON.stringify(p)}`,
1830+
);
1831+
17151832
// ── The #9389 ruling: an exemption is a CLOSED list ───────────────────────
17161833
//
17171834
// The ruling's own load-bearing clause is that the boundary stays enumerated:

0 commit comments

Comments
 (0)