Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/runtime-dispatcher-discovery-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
"@objectstack/runtime": minor
---

feat(runtime): the dispatcher's two discovery bodies join the response envelope (#9813)

<!-- adr-0087: not-required (no-migration-prescription) Additive wire change:
`GET /.well-known/objectstack` (always dispatcher-owned) and the REST-less
fallback `GET {prefix}/discovery` gain `success: true` beside the existing
`data` key — nothing authorable and no key is renamed, retired or removed, so
there is no conversion to register. Every measured reader (`@objectstack/client`
`connect()`'s `body.data || body`, the QA http-adapter's `'routes' in body`
discriminator, objectui's `typeof body.success === 'boolean' && 'data' in body`
unwrap) resolves the new shape to the same document. -->

`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.
2 changes: 1 addition & 1 deletion content/docs/api/index.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.

<Callout type="info">
**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)
Expand Down
11 changes: 6 additions & 5 deletions content/docs/protocol/kernel/http-protocol.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
Expand All@@ -123,6 +123,7 @@ Host: api.acme.com
**Response:**
```json
{
"success": true,
"data": {
"name": "ObjectOS",
"version": "1.0.0",
Expand DownExpand Up@@ -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.
</Callout>

**Why discovery matters:**
Expand Down
29 changes: 29 additions & 0 deletions packages/runtime/src/dispatcher-plugin.routes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
14 changes: 11 additions & 3 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) ──────────────────────────
Expand All@@ -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)`);
Expand Down
121 changes: 119 additions & 2 deletions scripts/check-route-envelope.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -681,17 +681,63 @@ 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.
*
* Only `<ctx>.json(<objectLiteral>, …)` 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<string>} receivers identifiers judged as response receivers.
* @returns {{bodies: number, unenveloped: number, errorWithoutMessage: number, errorCodeNotString: number, strayKeys: number, stringError: number, siblingCode: number, sites: Record<string, string[]>}}
*/
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,
Expand All@@ -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];
Expand DownExpand Up@@ -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');
Expand DownExpand Up@@ -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 ────────────────────────────────────────────────────────────────
Expand DownExpand Up@@ -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:
Expand Down
Loading