Skip to content

Commit 25eaaf5

Browse files
committed
fix(runtime): 无 setFallbackHandler 的适配器改以 warn 宣告声明式端点不可达 (#5400)
`setFallbackHandler` 是 metadata 声明的 `apis:` 端点抵达 handler 的**唯一**接缝, 且在 `IHttpServer` 上是可选成员。适配器不暴露它时,所有声明的端点永久不可服务, 调用方只拿到传输层的裸 404 —— 与拼错路径毫无区别。 此前这条只记 `debug`。该级别曾经是对的:在非空 `apis:` 于发布期被整体拒绝(#4936) 的年代,没有任何部署可能"缺"什么,因为没有任何部署能声明什么。#5040 E7 发布翻转 终结了这个前提 —— 声明现在能发布,stack 也确实在发。而默认 `level: 'info'` 根本 不打印 `debug`(`packages/core/src/logger.ts` 的 `isEnabled`),运维侧等于零信号, 正是 AGENTS.md「Absence must be loud」(Route & surface ownership §3)点名要避免的 「留一个裸 404 让人去诊断」。 因此升为 `warn`,并按该节要求让这一行同时带上两半: - **后果** —— 该传输上任何 metadata 声明的 `apis:` 端点都不会被服务,只会回裸 404; - **补救** —— 换用实现了 `setFallbackHandler` 的 HTTP 适配器 (如 `@objectstack/plugin-hono-server`)。 刻意是 `warn` 而非 `error`:按「Degradation log levels」那个唯一判据 —— 降级后系统 对外看着正常,而它声称已持久化的东西其实没落盘吗? —— 答案是否,这里没有任何持久化 承诺。属功能性降级(某能力没挂上,下一个调用它的人自然会发现),与参考文案 「scheduled flows will not run until a job service is registered」同形。 新增 `dispatcher-plugin.fallback-absence-warn.test.ts` 把级别焊死(参照 #5226 「级别由门禁焊住」姿态):级别退回 `debug` 或误升 `error` 都会红,并另钉一条反向 用例 —— 合规适配器上这条完全不出声,保证 warn 是信号而非启动噪音。 反向验证(方向先于执行预判,属常规 Red 方向,因断言读的是发出级别这一**谓词**, 而非可能"因为什么都没产出而通过"的计数): - 改回 `debug` → 1-4 号用例红(`expected 'debug' to be 'warn'`),5 号仍绿; - 升到 `error` → 同样 1-4 号红(`expected 'error' to be 'warn'`)。 仅运维可见:无 API / schema / 路由变更,已在合规适配器(默认 `@objectstack/plugin-hono-server`)上的部署看不到任何新增输出。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh
1 parent 4b61cf3 commit 25eaaf5

3 files changed

Lines changed: 236 additions & 13 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): an HTTP adapter without `setFallbackHandler` now warns that declarative endpoints are unreachable (#5400)
6+
7+
`setFallbackHandler` is the ONE seam by which a metadata-declared `apis:`
8+
endpoint reaches a handler, and it is optional on `IHttpServer`. On an adapter
9+
that omits it, every declared endpoint is permanently unservable and the caller
10+
gets the transport's bare 404 — indistinguishable from a typo.
11+
12+
Until now the dispatcher announced that at `debug`, which the default
13+
`level: 'info'` does not print at all, so operators had no signal whatsoever.
14+
That level was correct only while a non-empty `apis:` was rejected wholesale at
15+
publish (#4936): no deployment could be missing anything, because none could
16+
declare anything. The #5040 E7 publish flip ended that premise — declarations
17+
publish now and stacks ship them — so the line is raised to `warn` and carries
18+
both halves AGENTS.md's "Absence must be loud" requires:
19+
20+
- **consequence** — every metadata-declared `apis:` endpoint is UNREACHABLE on
21+
this transport and will answer a bare 404;
22+
- **remedy** — compose an HTTP adapter that implements `setFallbackHandler`
23+
(e.g. `@objectstack/plugin-hono-server`).
24+
25+
`warn` and deliberately not `error`: this is a functional degradation (a
26+
capability is not mounted, and its next caller finds out), not a durability one
27+
— nothing here claims to have persisted anything. The level is welded by
28+
`packages/runtime/src/dispatcher-plugin.fallback-absence-warn.test.ts`, which
29+
fails both on a slide back to `debug` and on escalation to `error`, and pins
30+
that a conforming adapter stays silent.
31+
32+
Operator-visible only: no API, schema or routing change. A deployment already on
33+
a conforming adapter (the default `@objectstack/plugin-hono-server`) sees
34+
nothing new.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The declarative-endpoint seam's ABSENCE is announced at `warn` (#5400).
5+
*
6+
* ## Why a whole file exists to hold one log level
7+
*
8+
* `packages/runtime/src/dispatcher-plugin.ts` mounts declarative `apis:`
9+
* endpoints through exactly one seam — `IHttpServer.setFallbackHandler`, which
10+
* is OPTIONAL on the contract and feature-detected with `typeof === 'function'`.
11+
* On an adapter that omits it there is no second path: every endpoint a stack
12+
* declared in metadata is unreachable, forever, and the transport answers the
13+
* bare 404 it would answer for a typo.
14+
*
15+
* That line used to be `debug`, and correctly so — while a non-empty `apis:`
16+
* was rejected WHOLESALE at publish (#4936), no deployment could be missing
17+
* anything, because no deployment could declare anything. The #5040 E7 publish
18+
* flip (`packages/spec/src/api/endpoint-publish-gate.ts`, "This module is that
19+
* flip") ended that premise: declarations publish now and stacks ship them.
20+
* A `debug` under the default `level: 'info'` is not printed at all
21+
* (`isEnabled`, `packages/core/src/logger.ts`), so the operator's signal for a
22+
* silently-dead surface was nothing whatsoever — the exact outcome AGENTS.md's
23+
* "Absence must be loud" (Route & surface ownership §3) forbids.
24+
*
25+
* ## The level is the assertion, so it is welded here
26+
*
27+
* A log level is one identifier away from silence and nothing else in the
28+
* build notices it change. Following #5226's posture — "the level is held by a
29+
* gate, not by the comment next to it" — these tests fail if the line slides
30+
* back to `debug` (invisible again) AND if it is escalated to `error` (wrong
31+
* class). `warn` is what AGENTS.md's "Degradation log levels" question yields:
32+
* nothing here claims to have PERSISTED anything, so this is a functional
33+
* degradation — a capability that is not mounted, whose next caller finds out —
34+
* not a durability one.
35+
*
36+
* Reverse verification, direction predicted BEFORE running: restoring
37+
* `ctx.logger.debug` in the seam-absent branch must turn the level pins RED
38+
* (this is the ordinary direction — the pins read a predicate on the emitted
39+
* level, not a count that can pass by producing nothing). Confirmed: with
40+
* `debug` restored, `emitted at warn` and `never at debug` both fail on the
41+
* captured line, and the consequence/remedy pins fail with "no warn line".
42+
*
43+
* Harness note: the fake server is the `dispatcher-plugin.routes.test.ts`
44+
* shape. It is the honest one for this branch — the absent member is spelled
45+
* by simply not being there, which is what the contract tells consumers to
46+
* probe for, and a real adapter cannot express "I omit this" any better.
47+
*/
48+
49+
import { describe, it, expect } from 'vitest';
50+
51+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
52+
53+
interface LogLine { level: string; message: string; meta?: Record<string, any> }
54+
55+
/**
56+
* A server WITHOUT `setFallbackHandler` — the member simply absent, the shape
57+
* the contract documents (`packages/spec/src/contracts/http-server.ts`: "an
58+
* adapter that cannot express a not-found hook simply omits it").
59+
*/
60+
function makeFallbacklessServer() {
61+
const noop = () => { /* route registration is not what this file tests */ };
62+
return {
63+
get: noop, post: noop, put: noop, delete: noop, patch: noop,
64+
} as any;
65+
}
66+
67+
/** The same server, plus the seam — the control case. */
68+
function makeSeamedServer() {
69+
const server = makeFallbacklessServer();
70+
server.setFallbackHandler = () => { /* installed, never invoked here */ };
71+
return server;
72+
}
73+
74+
function makeCtx(fakeServer: any) {
75+
const logs: LogLine[] = [];
76+
const kernel = {
77+
getService: () => undefined,
78+
getServiceAsync: async () => undefined,
79+
};
80+
const ctx = {
81+
getKernel: () => kernel,
82+
getService: (name: string) => (name === 'http.server' ? fakeServer : undefined),
83+
environmentId: undefined,
84+
logger: {
85+
info(message: string, meta?: any) { logs.push({ level: 'info', message, meta }); },
86+
warn(message: string, meta?: any) { logs.push({ level: 'warn', message, meta }); },
87+
error(message: string, meta?: any) { logs.push({ level: 'error', message, meta }); },
88+
debug(message: string, meta?: any) { logs.push({ level: 'debug', message, meta }); },
89+
},
90+
hook: () => {},
91+
on: () => {},
92+
} as any;
93+
return { ctx, logs };
94+
}
95+
96+
/** Every line that talks about the missing seam, at whatever level it came out. */
97+
const seamLines = (logs: LogLine[]) => logs.filter((l) => l.message.includes('setFallbackHandler'));
98+
99+
async function bootWith(server: any) {
100+
const { ctx, logs } = makeCtx(server);
101+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
102+
await plugin.start?.(ctx);
103+
return logs;
104+
}
105+
106+
describe('dispatcher declarative-endpoint seam — absence is loud (#5400)', () => {
107+
it('announces the missing seam at `warn` — never `debug`, never `error`', async () => {
108+
const logs = await bootWith(makeFallbacklessServer());
109+
110+
const lines = seamLines(logs);
111+
// Said ONCE, at boot, not once per anything.
112+
expect(lines).toHaveLength(1);
113+
// The level IS the fix. `debug` is the pre-#5400 state and is invisible
114+
// under the default `info`; `error` is the over-escalation AGENTS.md's
115+
// durability question rules out (nothing here claims persistence).
116+
expect(lines[0].level).toBe('warn');
117+
expect(logs.filter((l) => l.level === 'debug' && l.message.includes('setFallbackHandler'))).toEqual([]);
118+
expect(logs.filter((l) => l.level === 'error')).toEqual([]);
119+
}, 60_000);
120+
121+
it('names the CONSEQUENCE: declared endpoints are unreachable and answer a bare 404', async () => {
122+
const logs = await bootWith(makeFallbacklessServer());
123+
124+
const line = seamLines(logs).find((l) => l.level === 'warn');
125+
expect(line).toBeDefined();
126+
const msg = line!.message;
127+
// What is lost: not "some routes", but every endpoint declared in
128+
// metadata, on this transport.
129+
expect(msg).toMatch(/metadata-declared/);
130+
expect(msg).toMatch(/`apis:`/);
131+
expect(msg).toMatch(/UNREACHABLE/);
132+
// And what the caller sees instead — the bare 404 that reads like a
133+
// typo and sends operators hunting in the wrong place.
134+
expect(msg).toMatch(/bare 404/);
135+
}, 60_000);
136+
137+
it('names the REMEDY: compose an adapter that implements the seam', async () => {
138+
const logs = await bootWith(makeFallbacklessServer());
139+
140+
const msg = seamLines(logs).find((l) => l.level === 'warn')!.message;
141+
// The member to implement...
142+
expect(msg).toMatch(/setFallbackHandler/);
143+
// ...and a concrete adapter that already does, so the remedy is
144+
// actionable without reading the contract first.
145+
expect(msg).toMatch(/@objectstack\/plugin-hono-server/);
146+
}, 60_000);
147+
148+
it('carries the affected mount prefix as structured meta', async () => {
149+
const logs = await bootWith(makeFallbacklessServer());
150+
151+
const line = seamLines(logs).find((l) => l.level === 'warn')!;
152+
expect(line.meta).toMatchObject({
153+
mount: '/api/v1/apps/',
154+
declarativeEndpoints: 'unreachable',
155+
});
156+
}, 60_000);
157+
158+
it('stays SILENT when the adapter does expose the seam', async () => {
159+
const logs = await bootWith(makeSeamedServer());
160+
161+
// The counter-case that keeps the warn a signal instead of boot noise:
162+
// on a conforming adapter nothing is missing, so nothing is announced.
163+
expect(seamLines(logs)).toEqual([]);
164+
// And the positive line is the one that gets printed instead.
165+
expect(logs.some((l) => l.level === 'info' && l.message.includes('Declarative endpoint dispatch step armed')))
166+
.toBe(true);
167+
}, 60_000);
168+
});

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1432,19 +1432,40 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
14321432
executes: true,
14331433
});
14341434
} else {
1435-
// Still `debug`, and that is now UNDER-STATED — tracked by
1436-
// #5400, deliberately not changed here (#5399 is comment-only).
1437-
// The reason this was debug has expired: it read "no stack can
1438-
// declare an endpoint yet", which stopped being true at the
1439-
// #5040 E7 publish flip. Declarations exist now, so on an
1440-
// adapter without this seam they are silently unservable and
1441-
// the operator's only signal is a line that the default `info`
1442-
// level does not even print. This is the "absence must be loud"
1443-
// case (AGENTS.md, Route & surface ownership §3); #5400 raises
1444-
// it to `warn` carrying the consequence and the remedy.
1445-
ctx.logger.debug(
1446-
'[dispatcher] http.server exposes no `setFallbackHandler`; declarative endpoints '
1447-
+ 'would be unreachable on this transport.',
1435+
// ── Absence must be loud (AGENTS.md, Route & surface ownership
1436+
// §3) ────────────────────────────────────────────────────────
1437+
// `warn` since #5400. This was `debug`, on a reason that has
1438+
// since expired: it read "no stack can declare an endpoint yet",
1439+
// true only while a non-empty `apis:` was rejected WHOLESALE at
1440+
// publish. The #5040 E7 flip ended that — declarations publish
1441+
// now and stacks ship them — so on an adapter without this seam
1442+
// they are legitimately, permanently unservable. At `debug` that
1443+
// was not a signal at all: the default `level: 'info'` does not
1444+
// print it (`isEnabled`, `packages/core/src/logger.ts`), leaving
1445+
// a bare 404 as the only evidence — precisely the "leave a bare
1446+
// 404 to be diagnosed" outcome the rule names.
1447+
//
1448+
// `warn`, NOT `error`, deliberately. AGENTS.md's "Degradation log
1449+
// levels" question — does the system look normal from outside
1450+
// while something it claims is PERSISTED did not land? — answers
1451+
// no: nothing here claims durability. This is a functional
1452+
// degradation (a capability is not mounted, and the next caller
1453+
// of it finds out), the same shape as the reference text
1454+
// "scheduled flows will not run until a job service is
1455+
// registered". `dispatcher-plugin.fallback-absence-warn.test.ts`
1456+
// welds the level so a quiet slide back to `debug` fails.
1457+
//
1458+
// The line owes both halves the rule demands, and carries them
1459+
// in the first (and only) thing it prints:
1460+
// consequence — every metadata-declared `apis:` endpoint is
1461+
// unreachable on this transport, answering a bare 404;
1462+
// remedy — compose an adapter that implements the seam.
1463+
ctx.logger.warn(
1464+
'[dispatcher] http.server exposes no `setFallbackHandler`: every metadata-declared '
1465+
+ '`apis:` endpoint is UNREACHABLE on this transport and will answer a bare 404. '
1466+
+ 'Fix: compose an HTTP adapter that implements `setFallbackHandler` '
1467+
+ '(e.g. `@objectstack/plugin-hono-server`).',
1468+
{ mount: appEndpointMountPrefix(prefix), declarativeEndpoints: 'unreachable' },
14481469
);
14491470
}
14501471

0 commit comments

Comments
 (0)