Skip to content

Commit 7f62536

Browse files
claude[bot]claude
andauthored
fix(types,runtime): a declared capability absence is reported once per route per process, at warn (#17854)
Fixes #14656 Clause-②: no A **declared capability absence** — a 5xx the platform chose because the deployment did not install an optional service — is now reported **once per route per process at `warn`**, naming the missing service. Everything else reaching `logServerFault` keeps the per-request `error` line #14310 shipped. This is the execution of the maintainer ruling recorded at [`5528582503`](#14656 (comment)) (decision batch #23, 2026-09-03, verbatim 「同意」 to **B + C**), released for dispatch by the director seat at [`5644019323`](#14656 (comment)). ## The file face — five paths, and `packages/spec` is not one of them | path | what changes | |:--|:--| | `packages/types/src/server-fault-log.ts` | **the only non-test source change.** The predicate, the (route, process) registry, and the `warn` branch inside `logServerFault`. | | `packages/types/src/server-fault-log.test.ts` | 11 new pins on the funnel; one existing fixture repointed off the absence family (it was `503 SERVICE_UNAVAILABLE`, which is now the ruled exception — the behaviour that case is about is unchanged and belongs to the fault branch). | | `packages/runtime/src/declared-capability-absence-warn-once.test.ts` | **new.** The door-level pins, the two-door agreement pin, and the wire-bytes block. | | `packages/runtime/src/dispatcher-5xx-always-logged.test.ts` | the returned-exit fixture repointed to a genuine fault, plus one door-level guard so this file cannot go green believing `/notifications` still costs an `error` line. | | `.changeset/14656-declared-capability-absence-warn-once.md` | patch on `@objectstack/types` and `@objectstack/runtime` — the packages whose logging changes, which is what the ruling asked for. | ⛔ **Nothing under `packages/spec/` is touched.** `unavailable.ts` imports `serviceUnavailableMessage` from `@objectstack/spec/system` and this change needed no edit there. The one new spec reference is a **type-only** import of `StandardErrorCode` into `packages/types`, which proves at compile time that the two code spellings are catalogued ADR-0112 codes. ## One predicate, one place — and why that is stronger than one import The ruling's constraint is «⛔ Not spelled once per door: the REST door and the dispatcher read the same predicate». The delivered design applies the predicate **inside the shared funnel** rather than exporting it for two doors to call: - `sendError` (`packages/types/src/response-envelope.ts:227`) is the single exit for every nested-envelope 5xx in `packages/rest`; - the runtime dispatcher calls `logServerFault` at its three exits (`dispatcher-plugin.ts` :318, :394, :656). Both already pass through the funnel, so there is no per-door spelling to drift **and no door can opt out by forgetting a call**. Being unable to spell it twice is stronger than agreeing to spell it once. It reuses the declared-5xx vocabulary that already exists rather than inventing one: `declaresServerFault` (`packages/types/src/error-leak.ts:289`) is the same "the producer declared this shape" read that `@objectstack/rest`'s `declaredServerFaultAnswer` gates on, and the absence family is the ADR-0112 `code` the producer already declared (`NOT_IMPLEMENTED` / `SERVICE_UNAVAILABLE`). Three boundaries, all fail-loud, all pinned: - **A thrown 5xx keeps its `error` line even when it declared `501`.** The thrown exit hands the funnel the throw and no envelope `code`, so it is not an absence here. The half that carries a stack stays loud. - **A door that supplies no route coordinates is demoted to `warn` but never suppressed.** An un-keyed bucket would collapse every unnamed route into one entry — the same shape as the global "first N" the ruling forbids for hiding the second route. - **The registry has a ceiling (512) and at the ceiling stops adding rather than evicting**, so an unrecorded route reports every time. Eviction would silently re-quiet whichever route was pushed out. The live key space is bounded anyway: `instrumentRouteHandler` parks the route PATTERN, not the raw URL. ## ⚠️ The contract-review declaration — measured, and it says `no` The claim declared `Clause-②: yes` when it was dispatched, and the dispatch order explained why: the seat read the ruling's own `no` as right about the wire but incomplete, because «the same ruling puts the predicate in `@objectstack/types` where two packages must read it … so it has to cross a package boundary, and a symbol exported from that package's entry is a new published symbol». **That premise does not hold for the delivered design, so the ruling's `no` is right and the seat's reading was wrong.** The predicate never crosses the boundary — it is applied inside the funnel both packages already call — and every symbol this change adds is module-private. Measured on this branch: - `git diff BASE..HEAD -- packages/types/src/server-fault-log.ts | grep -E '^[+-][^+-].*\bexport\b'` — **no output**: not one `export` line added or removed. - `packages/types/src/index.ts` and `packages/types/package.json` — **untouched** (0 paths in the diff), so the entry and the `exports` map are byte-identical. - The seven new identifiers, grepped against the rebuilt `packages/types/dist/index.d.ts`: six score 0; the single hit for `isDeclaredCapabilityAbsence` is a `{@link}` inside a doc comment at line 1392, and `grep -E "declare (function|const) …"` finds **no declaration** for any of them. ✅ **Corrected at review, which is the seat's act and not the dev's.** The `Clause-②:` line at the top of this body now reads **`no`**, and `needs:contract-review` is off **both** carriers — this PR and card #14656. The record is the contract review at [`5646183631`](#17854 (comment)), written on head `44e14e367`, which re-measured the surface independently of the reading above and reached the same answer the maintainer ruling had already written down: «Clause-②: no (re-read on the final diff; if any response byte moves, stop and report)». ## ⛔ No wire change — measured, not asserted The ruling's condition is «if any response byte moves, stop and report». The `#14656 — the wire does not move` block captures the full `status + JSON.stringify(body)` for a declared absence (over three requests) and for a fault, and asserts the exact strings. That block is written to be runnable on the pre-change tree: it names no `warn`, no count and nothing else this card introduces. Run against the base funnel (`packages/types/src/server-fault-log.ts` restored to `758ac4097`, everything else this branch): - **wire block on the base funnel: 2 passed.** The bytes this branch asserts are the bytes `main` already answered — a before/after measurement, not a claim. - **whole new suite on the base funnel: 4 failed / 3 passed.** The four behavioural pins discriminate; the three that pass are the two wire-byte tests and the undeclared-500 control, whose behaviour is unchanged by design. - Restore proved byte-identical by blob hash (`7ed86cc3…`), `git diff HEAD` empty, whole-tree `git status --porcelain` empty. ## The ablation — making the two doors disagree The two-door pin is only worth something if it can fail. Mutation: gate the demotion on `input.request !== undefined`, which the dispatcher supplies and `sendError` does not, so the doors disagree about one envelope. - On-disk proof the mutation landed: the anchor line count went 1 → 0, the injected marker 0 → 2, and the blob hash moved off HEAD's. - `@objectstack/types` rebuilt, then `node scripts/ablation-dist-preflight.mjs @objectstack/types 'ABLATION_DOOR_SPLIT'` → **exit 0**, marker live in `dist/index.js` and `dist/index.mjs`. - **Ablation run: 1 failed / 6 passed** — exactly the two-door pin, and the failure names what it lost: `door 2 must not disagree with door 1 about the same envelope: expected [] to have a length of 1 but got +0`. - Restore: blob hash back to `7ed86cc3…`, whole-tree `git status` clean, rebuild, `--absent` preflight **exit 0** (`marker absent from all 12 built files`). A first attempt planted the marker inside a `/* comment */`, which esbuild strips, so the plant-leg preflight exited 1 on a marker-choice artifact; the run above replaces it with a marker that survives bundling. Both runs went red on the same assertion. ## Verification All at `44e14e367` (this branch merged with `origin/main` at `3c86008e2`). Exit codes captured before any pipe; each verdict read from the command's own printed line. - **Derived gates: 58/58 green.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, reconciled with exit codes recorded: `58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUN` — «a DERIVED zero — all 58 recorded an exit code and none of them is 3». - Two of them first exited **3 — `PREREQUISITE NOT MET`, not a finding**: `check:dual-build-cjs-loads` and `check:type-check-debt` both read built output. After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (**72/72 successful**) both exit 0, the second reporting «5 ledger entr(ies) re-measured in 76.5s, 55 raw tsc error(s) total, none above its recorded number». - **`@objectstack/types`**: `test` 22 files / 666 tests passed · `test:repo` 1 file / 7 passed · `typecheck` clean. - **`@objectstack/runtime`**: `test` 261 files / 3631 tests passed · `test:repo` 2 files / 69 passed · `typecheck` clean (which for this package also runs `check:test-typecheck`). - **Repo-wide `pnpm lint`: exit 0**, the full `eslint . --no-inline-config` run — no narrowing to declare. - `check:nul-bytes` green, plus an independent control-character scan of all five changed paths: 0 hits. - Heavy runs serialised through `scripts/pm/os-verify-lock.sh`; every verdict quoted above is the wrapper's `VERDICT command-exit` line. **NOT MEASURED, named rather than left to read as green:** the five CI jobs `dispatch-gates` reports as scheduled by these paths but having no local invocation (`Test Core`, `Temporal Conformance`, `Dogfood Regression Gate`, `Dogfood Verify CLI`, `Build Core`), the five families whose argv takes a value only CI supplies, the 11 declared-wide-population families and the 50 artifact-roster families. No browser/dogfood verification was done: the diff moves no rendered surface and no response byte. ## The order's line readings — all four held The dispatch flagged its own re-derived positions as timestamped readings rather than coordinates. Checked by symbol on this tree, all four are correct: `declaredServerFaultAnswer` at `error-response.ts:606`, `boundedDeclaredRefusalMessage` at `:775`, `logServerFault` in `packages/types/src/server-fault-log.ts`, and `capabilityUnavailable` at `unavailable.ts:60-61`. Nothing to charge back. ## Acceptance notes - **The REST door names no route to the funnel, so its declared absences are demoted to `warn` but not deduped.** `sendError` passes only `requestId`; `instrumentRouteHandler` — the one producer of the parked route coordinates — is applied solely by the dispatcher's server proxy, and `rest-api-plugin` mounts its direct-mount registrars on the raw server, so nothing parks coordinates for that door today. Reading them in `sendError` would therefore be dead code. The reachable population is one route (`external-datasource-routes.ts:383`, a `503`), which is not polled. *Noted, not filed* — it is an observation about a door's coordinates, not a reproducible defect, a contract violation, or a metadata-authoring trap. - **`vi.setConfig({ testTimeout: 30_000 })` in the new runtime file is paid for the per-test isolation**, not hidden slowness: each test re-executes the dispatcher's module graph so the per-process registry starts empty, and the first one measured over 5s on this shared box. *Noted, not filed.* --- _Generated by [Claude Code](https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c577c1 commit 7f62536

5 files changed

Lines changed: 716 additions & 19 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/types": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
A **declared capability absence** — a 5xx answered because the deployment did not install an optional service — is now reported **once per route per process at `warn`**, naming the missing service, instead of one `error` line per request. Every other 5xx keeps the per-request `error` line #14310 shipped.
7+
8+
Measured before the change, on a stock showcase boot: `GET /api/v1/ai/*` (the cloud-only AI service's declared `501 NOT_IMPLEMENTED`) printed one `error`-level line per request, and Studio opens it unprompted. A deployment that is working exactly as configured was training the channel built to mean "an operator must look" into noise — which is the failure mode `--log-level`-watching operators learn as "skim the errors".
9+
10+
- **What counts as an absence** is the envelope the door composed: a producer-declared 5xx (`declaresServerFault` — the repo's existing declared-5xx predicate) whose ADR-0112 `code` is `NOT_IMPLEMENTED` or `SERVICE_UNAVAILABLE`. Nothing is invented to recognise one; the code the producer already declared *is* the declaration.
11+
- **The predicate is applied inside the shared funnel** (`logServerFault`, `@objectstack/types`), not at each door, so `sendError`'s nested-envelope exit and the runtime dispatcher read one answer by construction. A door cannot opt in, opt out, or drift.
12+
- **The dedupe key is (route, process).** A restart reports again, and a second, different route reports on its own — deliberately not a global "first N", which is the shape that hides the second route. A door that supplies no route coordinates is demoted to `warn` but never suppressed: an un-keyed bucket is that same hiding shape.
13+
- **A thrown 5xx keeps its `error` line even when it declared `501`.** The thrown exit hands the funnel the throw and no envelope `code`, so it is not recognised as an absence — fail-loud for the half that carries a stack.
14+
15+
⛔ **No wire byte moves.** Status, `code`, `message` and body shape are unchanged at both doors; this changes a log level and a count. The response bytes are pinned in `packages/runtime/src/declared-capability-absence-warn-once.test.ts`, and that block runs green on the pre-change tree too, which is what makes it a before/after measurement rather than a claim.
16+
17+
Operators who were alerting on `[5xx]` at `error` level for an uninstalled optional service will now see one `warn` line per route per process instead. The line says so in its own text: `(declared capability absence — reported once per route per process)`.
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#14656] A DECLARED CAPABILITY ABSENCE is reported once per route per
5+
* process, at `warn`, naming the missing service — driven through the REAL
6+
* dispatcher and the REAL REST envelope writer.
7+
*
8+
* ## The ruling this pins
9+
*
10+
* Maintainer ruling 2026-09-03 (decision batch #23, verbatim reply 「同意」 to
11+
* this card's **B + C**):
12+
*
13+
* > A **declared capability absence** — a 5xx the platform chose because the
14+
* > deployment did not install an optional service (the `NOT_IMPLEMENTED` /
15+
* > `SERVICE_UNAVAILABLE` family answering a configuration fact) — is a
16+
* > configuration fact, not a fault: it is reported **once per route per
17+
* > process**, at `warn`, naming the missing service, and then stays quiet for
18+
* > that route. Everything else that reaches `logServerFault` keeps the shipped
19+
* > per-request `error` line.
20+
*
21+
* ## What was measured before it
22+
*
23+
* On a stock showcase boot (#14656 comment, 2026-09-04), `GET /api/v1/ai/*` —
24+
* the cloud-only AI service's declared `501 NOT_IMPLEMENTED` — printed one
25+
* `error`-level line **per request**, and Studio opens it unprompted. The
26+
* channel #14310 had just built to mean "an operator must look" was being
27+
* trained into noise by a deployment that is working exactly as configured.
28+
*
29+
* ## Why these four assertions and not others
30+
*
31+
* 1. **The second, different route.** The ruling names the global "first N"
32+
* throttle as "the shape that hides the second route", so a test showing
33+
* the first route going quiet is worth less than one showing a second one
34+
* still speaking. Both routes here answer the SAME code and the SAME
35+
* message from the SAME slot, so only the route can be discriminating.
36+
* 2. **The undeclared fault control.** Same process, same door, N requests —
37+
* N `error` lines. Without it, "quiet" and "broken" are the same colour.
38+
* 3. **The two doors, on one fixture envelope.** The ruling puts the predicate
39+
* in one place *because* a per-door spelling is what this repo has paid to
40+
* repair twice. Here the envelope the dispatcher really answers is read off
41+
* the wire and handed to the OTHER door (`sendError`, `@objectstack/types`
42+
* — the exit every nested-envelope 5xx in `packages/rest` takes), and both
43+
* must classify it the same way. ⚠️ The two lines are not byte-identical
44+
* and are not asserted to be: the dispatcher names its route and the
45+
* envelope writer has none to name. What must agree is the VERDICT — the
46+
* level, and that the line names the missing service.
47+
* 4. **The wire does not move.** The ruling's own condition is «if any
48+
* response byte moves, stop and report», so the bytes are captured rather
49+
* than asserted to be unchanged: the `the wire does not move` block below
50+
* runs green on `origin/main` at this branch's base too, which is what
51+
* makes it a before/after measurement instead of a claim.
52+
*/
53+
54+
import { describe, it, expect, vi } from 'vitest';
55+
56+
// Each test re-executes the dispatcher's module graph (see `boot` below), which
57+
// the default 5s budget does not cover on a shared box — measured: the FIRST
58+
// test paid 5s+ and timed out while the rest ran in ~1.4s each off vitest's
59+
// transform cache. The cost is the price of the per-test isolation the ruling's
60+
// "per process" key needs, so the budget is raised rather than the isolation
61+
// dropped.
62+
vi.setConfig({ testTimeout: 30_000 });
63+
64+
function makeFakeServer() {
65+
const handlers: Record<string, (req: any, res: any) => any> = {};
66+
const rec = (verb: string) => (path: string, handler: any) => {
67+
handlers[`${verb} ${path}`] = handler;
68+
};
69+
return {
70+
handlers,
71+
server: {
72+
get: rec('GET'),
73+
post: rec('POST'),
74+
put: rec('PUT'),
75+
delete: rec('DELETE'),
76+
patch: rec('PATCH'),
77+
},
78+
};
79+
}
80+
81+
function makeRes() {
82+
const res: any = {
83+
statusCode: undefined as number | undefined,
84+
body: undefined as any,
85+
status(c: number) { res.statusCode = c; return res; },
86+
header() { return res; },
87+
json(b: any) { res.body = b; return res; },
88+
end() { return res; },
89+
};
90+
return res;
91+
}
92+
93+
/**
94+
* Boot the real plugin over a fake transport, with a spied kernel logger —
95+
* in a FRESH module graph.
96+
*
97+
* ⚠️ `vi.resetModules()` is the load-bearing line, not boilerplate. The dedupe
98+
* registry is module state in `@objectstack/types` (that IS the ruling's "per
99+
* process" half), so without a reset the first test to touch a route would
100+
* silence it for every later test in this file, and their colour would depend
101+
* on execution order. Resetting gives each test its own process-equivalent,
102+
* which is also the only honest way to pin a per-process rule.
103+
*
104+
* `@objectstack/types` is aliased to its SOURCE for this package
105+
* (`packages/runtime/vitest.config.ts`), so the reset reaches the registry and
106+
* an edit to `packages/types/src` is visible here without a rebuild.
107+
*
108+
* Both doors are imported INSIDE this window so the dispatcher and `sendError`
109+
* share one registry — a two-door pin over two registries would prove nothing.
110+
*/
111+
async function boot(services: Record<string, any>) {
112+
vi.resetModules();
113+
const [{ createDispatcherPlugin }, { sendError }] = await Promise.all([
114+
import('./dispatcher-plugin.js'),
115+
import('@objectstack/types'),
116+
]);
117+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
118+
const kernel = {
119+
getService: (n: string) => services[n],
120+
getServiceAsync: async (n: string) => services[n],
121+
};
122+
const { server, handlers } = makeFakeServer();
123+
const ctx: any = {
124+
getKernel: () => kernel,
125+
getService: (n: string) => (n === 'http.server' ? server : undefined),
126+
environmentId: undefined,
127+
logger,
128+
hook: () => { },
129+
on: () => { },
130+
};
131+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
132+
await plugin.start?.(ctx);
133+
return { handlers, logger, sendError };
134+
}
135+
136+
const FAULT_PREFIX = '[5xx]';
137+
const lines = (spy: { mock: { calls: any[][] } }) =>
138+
spy.mock.calls.filter((c) => String(c[0]).startsWith(FAULT_PREFIX));
139+
140+
const REQ = { body: {}, query: {}, headers: {}, params: {} };
141+
142+
/** The analytics door, which throws and therefore answers an UNDECLARED 500. */
143+
const throwingAnalytics = (message: string) => ({
144+
analytics: {
145+
query: async () => { throw new Error(message); },
146+
getMeta: async () => ({ cubes: [] }),
147+
generateSql: async () => ({ sql: null }),
148+
},
149+
});
150+
151+
describe('#14656 — the dispatcher door', () => {
152+
it('reports a declared 501 ONCE across N requests, at warn, naming the missing service', async () => {
153+
const { handlers, logger } = await boot({});
154+
155+
for (const _ of [0, 1, 2, 3, 4]) {
156+
await handlers['GET /api/v1/notifications']({ ...REQ }, makeRes());
157+
}
158+
159+
expect(lines(logger.error), 'a configuration fact is not a fault').toHaveLength(0);
160+
const warned = lines(logger.warn);
161+
expect(warned, 'five requests, one line').toHaveLength(1);
162+
163+
const [message, meta] = warned[0];
164+
// `serviceUnavailableMessage('notification')` — the same remedy
165+
// sentence discovery publishes for that slot, which is what "naming the
166+
// missing service" means here: the line says WHICH package to install.
167+
expect(String(message)).toContain('@objectstack/service-messaging');
168+
expect(String(message)).toContain('reported once per route per process');
169+
expect(meta).toMatchObject({
170+
status: 501,
171+
code: 'NOT_IMPLEMENTED',
172+
method: 'GET',
173+
path: '/api/v1/notifications',
174+
});
175+
});
176+
177+
it('a SECOND, DIFFERENT route still reports — the dedupe key is the route', async () => {
178+
const { handlers, logger } = await boot({});
179+
180+
await handlers['GET /api/v1/notifications']({ ...REQ }, makeRes());
181+
await handlers['GET /api/v1/notifications']({ ...REQ }, makeRes());
182+
await handlers['POST /api/v1/notifications/read']({ ...REQ, body: { ids: ['n1'] } }, makeRes());
183+
await handlers['POST /api/v1/notifications/read']({ ...REQ, body: { ids: ['n1'] } }, makeRes());
184+
185+
const warned = lines(logger.warn);
186+
expect(warned, 'one line per route, not one line per process').toHaveLength(2);
187+
expect(warned.map((c) => `${(c[1] as any).method} ${(c[1] as any).path}`)).toEqual([
188+
'GET /api/v1/notifications',
189+
'POST /api/v1/notifications/read',
190+
]);
191+
// Same slot, same code, same prose — so nothing but the route could
192+
// have told the two apart.
193+
expect((warned[0][1] as any).code).toBe('NOT_IMPLEMENTED');
194+
expect((warned[1][1] as any).code).toBe('NOT_IMPLEMENTED');
195+
expect(lines(logger.error)).toHaveLength(0);
196+
});
197+
198+
it('an UNDECLARED 500 on the same door is still loud, once per request', async () => {
199+
const { handlers, logger } = await boot(throwingAnalytics('still-loud-per-request'));
200+
201+
for (const _ of [0, 1, 2]) {
202+
await handlers['POST /api/v1/analytics/query'](
203+
{ body: { cube: 'x', measures: ['count'] }, query: {} },
204+
makeRes(),
205+
);
206+
}
207+
208+
expect(lines(logger.error), 'the #14310 rule is untouched for faults').toHaveLength(3);
209+
expect(lines(logger.warn)).toHaveLength(0);
210+
});
211+
212+
it('a declared absence and a fault coexist in one process without either changing the other', async () => {
213+
const { handlers, logger } = await boot(throwingAnalytics('coexist'));
214+
215+
await handlers['GET /api/v1/notifications']({ ...REQ }, makeRes());
216+
await handlers['POST /api/v1/analytics/query']({ body: { cube: 'x', measures: ['count'] }, query: {} }, makeRes());
217+
await handlers['GET /api/v1/notifications']({ ...REQ }, makeRes());
218+
await handlers['POST /api/v1/analytics/query']({ body: { cube: 'x', measures: ['count'] }, query: {} }, makeRes());
219+
220+
expect(lines(logger.warn)).toHaveLength(1);
221+
expect(lines(logger.error)).toHaveLength(2);
222+
});
223+
});
224+
225+
describe('#14656 — both doors read ONE predicate, on one fixture envelope', () => {
226+
it('the envelope the dispatcher answers is classified identically by the REST envelope writer', async () => {
227+
const { handlers, logger, sendError } = await boot({});
228+
229+
// ── Door 1: the runtime dispatcher, on the real route ──────────────
230+
const res = makeRes();
231+
await handlers['GET /api/v1/notifications']({ ...REQ }, res);
232+
233+
const fixture = {
234+
status: res.statusCode as number,
235+
code: res.body.error.code as string,
236+
message: res.body.error.message as string,
237+
};
238+
expect(fixture).toMatchObject({ status: 501, code: 'NOT_IMPLEMENTED' });
239+
240+
const dispatcherWarned = lines(logger.warn);
241+
expect(dispatcherWarned, 'door 1 demoted it').toHaveLength(1);
242+
expect(lines(logger.error)).toHaveLength(0);
243+
244+
// ── Door 2: `sendError`, the exit every nested-envelope 5xx takes ──
245+
// It takes no logger (it is reached from ~50 sites that have none), so
246+
// its channel is `console`. The CHANNEL differs; the VERDICT must not.
247+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
248+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
249+
try {
250+
sendError(makeRes(), fixture.status, fixture.code as any, fixture.message);
251+
252+
const restWarned = lines(warnSpy);
253+
expect(restWarned, 'door 2 must not disagree with door 1 about the same envelope').toHaveLength(1);
254+
expect(lines(errorSpy), 'door 2 must not keep the fault line the other door dropped').toHaveLength(0);
255+
256+
// Both lines name the missing service and both declare the
257+
// suppression — the two halves of "reported once, naming what is
258+
// absent" that a per-door spelling would drift on first.
259+
for (const line of [String(dispatcherWarned[0][0]), String(restWarned[0][0])]) {
260+
expect(line.startsWith(FAULT_PREFIX)).toBe(true);
261+
expect(line).toContain('@objectstack/service-messaging');
262+
expect(line).toContain('reported once per route per process');
263+
}
264+
} finally {
265+
warnSpy.mockRestore();
266+
errorSpy.mockRestore();
267+
}
268+
});
269+
});
270+
271+
describe('#14656 — the wire does not move', () => {
272+
/**
273+
* ⚠️ This block is written to be RUNNABLE AT THIS BRANCH'S BASE. It names
274+
* no `warn`, no count and nothing else this card introduces, so running it
275+
* on `origin/main` and here answers one question: did any response byte
276+
* move? The ruling's condition — «if any response byte moves, stop and
277+
* report» — is a measurement, and this is the instrument.
278+
*/
279+
it('answers the same bytes it answered before this change, on every request', async () => {
280+
const { handlers } = await boot({});
281+
282+
const seen: string[] = [];
283+
for (const _ of [0, 1, 2]) {
284+
const res = makeRes();
285+
await handlers['GET /api/v1/notifications']({ ...REQ }, res);
286+
seen.push(`${res.statusCode} ${JSON.stringify(res.body)}`);
287+
}
288+
289+
// Identical on every request: the dedupe changes what is LOGGED, never
290+
// what is ANSWERED — a caller cannot tell the first request from the
291+
// hundredth.
292+
expect(new Set(seen).size, 'the answer must not depend on how many times it was asked').toBe(1);
293+
expect(seen[0]).toBe(
294+
'501 {"success":false,"error":{"code":"NOT_IMPLEMENTED",'
295+
+ '"message":"Install @objectstack/service-messaging to enable",'
296+
+ '"httpStatus":501}}',
297+
);
298+
});
299+
300+
it('answers the same bytes for a fault, too', async () => {
301+
const { handlers } = await boot(throwingAnalytics('wire-unchanged-for-faults'));
302+
303+
const res = makeRes();
304+
await handlers['POST /api/v1/analytics/query'](
305+
{ body: { cube: 'x', measures: ['count'] }, query: {} },
306+
res,
307+
);
308+
309+
expect(`${res.statusCode} ${JSON.stringify(res.body)}`).toBe(
310+
'500 {"success":false,"error":{"code":"INTERNAL_ERROR","message":"wire-unchanged-for-faults","httpStatus":500}}',
311+
);
312+
});
313+
});

0 commit comments

Comments
 (0)