Skip to content

Commit 2b08a72

Browse files
claude[bot]claude
andauthored
fix(runtime): a repeated ?version= on GET /packages/:id answers 400 VALIDATION_ERROR from the one shared rule, and @objectstack/rest publishes it (#17672) (#17815)
Fixes #17672 Clause-②: yes `GET /api/v1/packages/:id?version=a&version=b` answered `404`. This repo already had a landed answer for exactly that condition on exactly that route — `400 VALIDATION_ERROR` in the ADR-0112 nested body — and one implementation of it. What blocked PR #17668 from calling it was reachability: `@objectstack/rest` declares a single export subpath and `query-multiplicity.ts` was not on it. Triage ruled route 1 at [`5643017415`](#17672 (comment)) and refused routes 2 and 3. This branch executes route 1. ⛔ No copy of the rule was made in `packages/runtime`, under any framing. --- ## 1. The repro, driven first — one host, four requests Before any edit, on the branch point `310760d22`, through the real `HttpDispatcher` and a real `SchemaRegistry`: ``` ### repeated ?version=a&version=b status=404 code=RESOURCE_NOT_FOUND message="Package 'com.acme.crm' — the \"version\" query parameter was supplied 2 times, so this read names no single version. Supply it at most once." ### genuine 404 — version I do not have status=404 code=RESOURCE_NOT_FOUND message="Package 'com.acme.crm' version '99.0.0' not found — installed version is '1.0.0'" ### genuine 404 — unknown id status=404 code=RESOURCE_NOT_FOUND message="Package 'com.absent.pkg' not found" ### unscoped 200 control status=200 code=(none) ``` The card said the two answers were indistinguishable by status. Measured, they were indistinguishable by `error.code` as well — all three refusals are `404 RESOURCE_NOT_FOUND`, so a client branching on either field could not separate a request-shape error from a not-found. After, same harness, same four requests: ``` ### repeated ?version=a&version=b status=400 code=VALIDATION_ERROR message="The \"version\" query parameter was supplied 2 times. Supply it at most once — this endpoint will not choose between conflicting values." ### genuine 404 — version I do not have status=404 code=RESOURCE_NOT_FOUND message="Package 'com.acme.crm' version '99.0.0' not found — installed version is '1.0.0'" ### genuine 404 — unknown id status=404 code=RESOURCE_NOT_FOUND message="Package 'com.absent.pkg' not found" ### unscoped 200 control status=200 code=(none) ``` The full body is the dispatcher's declared envelope, `{ success: false, error: { code: 'VALIDATION_ERROR', message, httpStatus: 400 } }`. `VALIDATION_ERROR` is derived by `buildApiError` from `standardErrorCodeForHttpStatus(400)`, the standard catalog's member for 400. ⛔ Nothing in `packages/spec` moves. ## 2. ⚠️ Premise ② was measured, and it does not hold in the reading the route-1 wording implies Triage's named premise: 「导出**那两个符号**就够,且导出它们不会连带把该模块的内部面一起拉上公开面」. **Half two holds.** Built `packages/rest/dist/index.d.ts` gains exactly two declarations, both of primitives: ``` declare function repeatedQueryParamMessage(name: string, count: number): string; declare function refuseRepeatedQueryParams(req: any, res: any, names: readonly string[]): boolean; ``` `SingleQueryRead`, `readSingleQueryValue`, `FILTER_SLOT_QUERY_PARAMS`, `assertFilterParamSuppliedOnce` and `repeatedFilterParamMessage` are all absent from the published surface (grep over the built `.d.ts`). The module's internal surface is not dragged along. **Half one is where the premise bends.** Nothing MORE than those two symbols is needed — so the hard fork's trigger (「若可达性需要导出的**不止那两个符号**」) does not fire, and this branch does not stop. But only ONE of the two is callable at this door, and the reason is structural rather than stylistic: `refuseRepeatedQueryParams` writes the answer itself, `res.status(400).json(…)`. A dispatcher domain has no `res` — it RETURNS `{ handled, response }`, and every error body on that surface is built by `buildApiError`. Driven, with the gate given a capturing `res`: ``` gate return = true gate body = {"status":400,"body":{"error":{"code":"VALIDATION_ERROR","message":"…"}}} BaseResponseSchema.safeParse(body).success = false envelopeViolations(body) = ["success is missing, must be a boolean"] ``` So the gate's body is not a legal body on this wire surface. The message function is the portable half; the gate is not. The door therefore calls `repeatedQueryParamMessage` and builds its body through `deps.error(msg, 400)`. ⚠️ **`refuseRepeatedQueryParams` is exported anyway, and that is deliberate rather than accidental.** The ruling names both symbols, and narrowing a ruling is a report rather than a dev's decision — so both are published, the barrel entry records which half travels and which does not, and this paragraph hands the question to the at-tier contract review that this PR waits on. If review decides a published symbol with no cross-package consumer should not ship, dropping `refuseRepeatedQueryParams` from the barrel is a one-line change that touches nothing else in this diff. ⚠️ `Clause-②: yes` is ⛔ NOT downgraded: `packages/rest`'s published surface widens on either outcome. ## 3. The answer comes from the shared implementation — a control that can fail The committed pin asserts the wire text BY DERIVATION (`toBe(repeatedQueryParamMessage('version', 2))`), so it cannot fail when the shared sentence moves. The falsifiable control is an ablation, run from the committed state: - Mutated `packages/rest/src/query-multiplicity.ts`, replacing `this endpoint will not choose between conflicting values.` with `ABLATION-17672 the shared sentence moved.` - On-disk proof, ⛔ not the editor's exit code: `grep -c` of the removed text `1 → 0`, of the injected text `0 → 1`; `git hash-object` of the path `e6887a55… → 1482e095…`, against the `HEAD` blob hash `e6887a55…`. - **Leg A** — `packages/rest`'s own literal pin, `rest-server-query-multiplicity.test.ts`: `15 failed | 17 passed`, exit 1. - **Leg B** — the dispatcher door re-driven, no rebuild: `message="The \"version\" query parameter was supplied 2 times. Supply it at most once — ABLATION-17672 the shared sentence moved."`, and a temporary literal assertion on the door went red. ⇒ the door's wire text follows the shared module's source. - **Leg C** — the committed derivation pin: still `13 passed`, exit 0, which is the behaviour it is written for. - Restored with `git checkout HEAD -- ABSOLUTE-PATH` from an `EXIT INT TERM` trap; `git diff HEAD` for that path empty afterwards. ⚠️ On why no rebuild was needed, stated so the reading is not mistaken for a dist-resolved one: `packages/runtime/vitest.config.ts` aliases `@objectstack/rest` to `../rest/src/index.ts`, so this suite resolves the specifier to SOURCE. `scripts/ablation-dist-preflight.mjs` is the preflight for the dist-resolved case and does not apply here — the ablation reaching the door with no build is itself the proof that source is the resolved path. ## 4. ⚠️ A pin was changed deliberately `packages/runtime/src/domains/packages-get-version-scope.test.ts` §4. As written for #17416 it asserted only `status !== 200` and that no installed row rode out, and its own docblock said the status was left out **on purpose**: 「⚠️ §4 asserts the DEFECT CLASS is closed (no `200` with the installed row), deliberately not the exact status … So this pin stays green when that rule lands here.」 ⚠️ Worth stating plainly, because the card and the dispatch both describe §4 as pinning the interim `404`: measured, it did not. It was written loose enough to survive this fix, and it would have stayed green through it. ⛔ That is not a reason to leave it — a door whose contract is `400 VALIDATION_ERROR` should have a pin that says so, and a loose pin that survives both answers cannot tell a reader which one is the contract. So §4 now pins the END state: the status, the code, the ADR-0112 nested body, and the message by derivation from `@objectstack/rest`'s function. §5 is new and pins the card's actual criterion — the three refusals read `400 VALIDATION_ERROR` / `404 RESOURCE_NOT_FOUND` / `404 RESOURCE_NOT_FOUND` and are mutually distinguishable. ⛔ The `404` it replaces is not treated as existing contract: it was the interim answer of an unreachable rule. ## 5. ⭐ The one line owed on every branch `packages/rest/src/query-multiplicity.ts` said the dispatcher's `/packages` domain "reads no `version`" — false since #17668 landed, and load-bearing, since it is part of why the rule needs only one home. The paragraph now states that the domain does read it, that the rule's home neither moved nor split, and that it serves two doors with one message. The `⚠️` clause under it records which half is portable across a package boundary, so the next author at a dispatcher domain does not reach for the gate. ## 6. Two published pages, re-read on this branch — one was already right, one is a live condition A docs-drift advisory on this PR listed 21 pages. ⛔ Its row count is not an instruction to edit anything; two rows actually bear on this diff, and both were re-read here rather than taken on trust. ⭐ **`content/docs/api/client-sdk.mdx` already documented the end state.** Its error table reads: > `| VALIDATION_ERROR | standard | 400 | validation | No | The **request** was refused before any record was validated — a repeated query parameter, a filter outside the allowlist, a malformed argument |` ⇒ a published page has been giving `400 VALIDATION_ERROR` for *a repeated query parameter* while this door answered `404 RESOURCE_NOT_FOUND` for exactly that condition. So this is not only a landed-precedent argument: the door contradicted a page the platform ships. ⛔ Nothing to edit there — the code now agrees with a page that was already correct, which is the opposite of drift. ⚠️ **`content/docs/kernel/contracts/metadata-service.mdx` is a live condition, not a mention.** Its route table says of `GET /api/v1/packages/:id`: *"a missing id answers `404 RESOURCE_NOT_FOUND`, message `Package 'ID' not found`"*. That sentence stays true only if the missing-id refusal survives with its exact message. §1 asserts both halves — `toBe` on the message and on `RESOURCE_NOT_FOUND` — and the new ordering pin asserts the message again with a repeated `?version=` riding along, so moving either turns a test red instead of silently falsifying that page. The test comment names the page, so the pin says what it protects. ⛔ No docs edit is owed. ⛔ `content/docs/releases/**` is read-only and untouched. ⚠️ And one gap carried forward rather than papered over: the advisory reports that `packages/rest/src/index.ts` yielded **no anchor**, so pages documenting what this PR exported there are invisible to that run — "not listed" is not "not affected" for that file, and nothing here should be read as coverage of it. ## 7. Not a breaking change, measured rather than assumed The `404` being replaced was introduced by #17668 (`1a25f4a8d`). `git merge-base --is-ancestor 1a25f4a '@objectstack/runtime@17.4.0'` exits **1**; two control commits from that tag's own history answer exit **0** on the same predicate, in a checkout where `git rev-parse --is-shallow-repository` is `false`. ⇒ it has never been published, so no released consumer can have branched on it. Changeset: `@objectstack/rest` minor (published exports), `@objectstack/runtime` patch. ## 8. Verification Run on the final commit, `git rev-parse --short HEAD` = `2969466d0` — the branch is `4f530786e` (a merge of `origin/main` `43df8db3a`, which touches only CI workflow and script paths, none overlapping this diff) plus one comment-only commit naming the two pages in §6. The whole table below was re-run on `2969466d0`, ⛔ not carried over from the earlier head. | run | result | |:--|:--| | `pnpm --filter @objectstack/rest test` | 190 files, **3181 passed**, 1 skipped · VERDICT command-exit 0 | | `pnpm --filter @objectstack/runtime test` | 260 files, **3623 passed** · VERDICT command-exit 0 | | `pnpm --filter @objectstack/runtime test:repo` | 2 files, **69 passed** · VERDICT command-exit 0 | | `pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck` | exit 0, both packages incl. `check:test-typecheck` | | `pnpm --filter '@objectstack/runtime^...' build` + both changed packages | exit 0 | | `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` | 72/72 successful | | `pnpm lint` (repo-wide, `eslint . --no-inline-config`) | exit 0 | | derived gate families (`scripts/pm/dispatch-gates.mjs`) | **61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN** — re-derived on `2969466d0` (family set identical), reconciled with `--ran` carrying an exit code per family, all 0 | On the earlier head two gates first answered `exit 3` — `PREREQUISITE NOT MET`, which each states in its own text is ⛔ not a pass and not a finding. `check:dual-build-cjs-loads` needed a whole-workspace `dist` and went to 0 after the full build; `check:type-check-debt` OOM'd under a `NODE_OPTIONS` heap cap tighter than the one it declares for itself, and went to 0 re-run without it. Both are plain 0 in the `2969466d0` sweep above (`5 ledger entries re-measured, 55 raw tsc errors, none above its recorded number`). Also run, because the derivation flags their rosters as sitting under a changed path: `check:error-status-conformance`, `check:error-code-casing`, `check:route-ledger-census`, `check:authz-resolver`, `check:published-readme-exports` — all exit 0. ## Acceptance notes **Filed** — #17813: `metadata-protocol`'s `repeatedQueryParamError` docblock says its wording is `packages/rest`'s "verbatim so a caller … is told the same thing twice", and driven, the two sentences differ at the first quoted character (`The "top"` vs `The 'top'`). Found because this card made the message importable for the first time; ⛔ not fixable here — `@objectstack/metadata-protocol` does not depend on `@objectstack/rest` in either direction that would allow the import, so it is a layering decision. That card is not addressed by this PR and remains open. **noted, not filed** — the multiplicity check sits AFTER the id lookup, so `GET /packages/UNKNOWN-ID?version=a&version=b` still answers `404 Package '…' not found` rather than `400`. That is #17416's deliberate ordering ("only a package that IS here can be at the wrong version") and this card moved the status of a refusal, not the order of two refusals. It is now pinned explicitly in §1 rather than left incidental, so a future re-ordering is a visible decision. Successor: the contract review on this PR, or whoever converts the `/packages` lane to a closed parameter set — which is #17667's territory, currently `needs-user-decision`. **noted, not filed** — `readRequestedVersion` in the domain still implements the count-not-shape predicate that `readSingleQueryValue` implements in `packages/rest` (`length > 1` refuses, `length === 1` unwraps, `length === 0` is absent). Importing the predicate too would be a third exported symbol plus its result type, which is exactly the widening the fork instruction guards; the door's copy also carries the `latest` sentinel, which is domain semantics the shared reader has no business knowing. Left as is, with the shared MESSAGE — the drift surface the module's header actually names — imported. Successor: none identified; this is a note for the reviewer of this diff and nothing else is queued against these lines. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c1078a5 commit 2b08a72

5 files changed

Lines changed: 281 additions & 32 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
'@objectstack/rest': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(runtime): a repeated `?version=` on `GET /packages/:id` is refused `400 VALIDATION_ERROR` in the repo's one message, and `@objectstack/rest` publishes the rule that owns it (#17672)
7+
8+
`GET /api/v1/packages/:id?version=a&version=b` answered **`404`**, with a second
9+
sentence written at that door. This repo already had a landed answer for exactly
10+
that condition on exactly that route — `400 VALIDATION_ERROR` in the ADR-0112
11+
nested body (#6307) — and one implementation of it, `refuseRepeatedQueryParams`
12+
/ `repeatedQueryParamMessage` in `packages/rest/src/query-multiplicity.ts`,
13+
whose header is the authority on the rule.
14+
15+
Driven before the change, one host, three refusals:
16+
17+
```
18+
GET /packages/com.acme.crm?version=a&version=b -> 404 RESOURCE_NOT_FOUND
19+
GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND
20+
GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND
21+
```
22+
23+
A client branching on the answer could not tell "your request named the
24+
parameter twice" from the two genuine not-founds. After:
25+
26+
```
27+
GET /packages/com.acme.crm?version=a&version=b -> 400 VALIDATION_ERROR
28+
GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND
29+
GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND
30+
```
31+
32+
The body is the dispatcher's declared envelope —
33+
`{ success: false, error: { code: 'VALIDATION_ERROR', message, httpStatus: 400 } }`
34+
— with `VALIDATION_ERROR` derived by `buildApiError` from
35+
`standardErrorCodeForHttpStatus(400)`, the standard catalog's member for 400.
36+
⛔ Nothing in `packages/spec` moves.
37+
38+
**What was actually blocking this was reachability, not judgement.**
39+
`@objectstack/rest` declares exactly one export subpath and that module was not
40+
on it, so #17668 could neither call the rule nor (correctly) copy it, and
41+
shipped the `404` with its own sentence instead. The barrel now publishes
42+
`repeatedQueryParamMessage` and `refuseRepeatedQueryParams`, and the dispatcher
43+
domain calls the message function — so the sentence a caller is told for a
44+
repeated parameter is the same one on every door that carries the rule, ⛔ never
45+
a second copy that drifts.
46+
47+
⚠️ The two published symbols are not interchangeable across a package boundary,
48+
and the barrel entry says so. `repeatedQueryParamMessage` is the portable half:
49+
a pure function of two primitives. `refuseRepeatedQueryParams` writes the bare
50+
ADR-0112 body onto a `res`, which suits handlers of that shape and ⛔ not a
51+
runtime dispatcher domain — measured, its body fails that surface's
52+
`BaseResponseSchema` with `success is missing, must be a boolean`.
53+
54+
**Not a breaking change, measured rather than assumed.** The `404` it replaces
55+
was introduced by #17668 (`1a25f4a8d`), which is not an ancestor of
56+
`@objectstack/runtime@17.4.0` (exit 1; two control commits from that tag's own
57+
history answer exit 0 on the same predicate, in a checkout
58+
`--is-shallow-repository` reports `false`). It has never been published, so no
59+
released consumer can have branched on it. Everything else about the door is
60+
unchanged: `?version=<installed>` and `?version=latest` still serve the
61+
installed row, an absent version and an unknown id still answer `404`, and a
62+
one-element array is still one occurrence.
63+
64+
Also corrected, on the module that owns the rule: its header said the
65+
dispatcher's `/packages` domain "reads no `version`" — load-bearing prose,
66+
since it is part of why the rule needs only one home. That stopped being true
67+
when #17668 landed. The paragraph now states what is true, which is that the one
68+
home did not move and now serves two doors.

packages/rest/src/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,24 @@ export { coerceRow } from './import-coerce.js';
4747
export type { CoerceContext, RefResolver } from './import-coerce.js';
4848
export { buildFieldMetaMap } from './export-format.js';
4949
export type { ExportFieldMeta } from './export-format.js';
50+
51+
// Query-parameter MULTIPLICITY — the repo's ONE rule for a single-valued
52+
// parameter supplied more than once (#6307 / #6877), published so the doors
53+
// OUTSIDE this package can answer it with that one implementation instead of a
54+
// second copy that drifts (#17672). `query-multiplicity.ts`'s header is the
55+
// authority on the rule; what belongs here is which half travels.
56+
//
57+
// `repeatedQueryParamMessage` is the portable half and the one the dispatcher's
58+
// `/packages` domain calls: it is a pure function of two primitives, so a
59+
// caller in any package gets the same sentence and no transport assumptions
60+
// ride along with it.
61+
//
62+
// ⚠️ `refuseRepeatedQueryParams` is the `res`-shaped gate, for a consumer that
63+
// has a response object to write — this package's own handlers, and any sibling
64+
// mounting handlers of that shape. It is NOT usable from a runtime dispatcher
65+
// domain: the body it writes is the bare ADR-0112 `{ error: { code, message } }`
66+
// and that surface's envelope needs the `success` / `httpStatus` siblings
67+
// `@objectstack/runtime`'s `buildApiError` adds (measured on #17672 — the gate's
68+
// body fails `BaseResponseSchema` with `success is missing, must be a boolean`).
69+
// ⛔ A dispatcher domain takes the message and builds its own body.
70+
export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-multiplicity.js';

packages/rest/src/query-multiplicity.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,28 @@ import { RPC_QUERY_ALIAS_SLOTS } from '@objectstack/spec/data';
5858
* #6307 landed the first copy of this rule in `package-routes.ts`, on the
5959
* `?version=` of that registrar's package read/delete routes. Those routes are
6060
* gone (#14503 — the dispatcher's `/packages` domain is their single
61-
* implementation, and it reads no `version`), so the rule now has one home:
62-
* here, for the `rest-server.ts` read points — ONE rule and one message, not
63-
* a second implementation that drifts.
61+
* implementation), so the rule has one home: here.
62+
*
63+
* That domain DOES read `?version=`: #17668 taught `GET /packages/:id` to
64+
* honour it. An earlier version of this paragraph said it read none, which
65+
* stopped being true the day that landed and left this module understating its
66+
* own scope (#17672). A repeated occurrence there is refused with
67+
* {@link repeatedQueryParamMessage} from here, so the home neither moved nor
68+
* split: the rule serves TWO doors — the `rest-server.ts` read points through
69+
* {@link refuseRepeatedQueryParams}, and that dispatcher domain through the
70+
* message function alone — ONE rule and one message, not a second
71+
* implementation that drifts.
72+
*
73+
* ⚠️ Why the dispatcher domain takes only the message: the two doors write
74+
* their bodies through different builders. {@link refuseRepeatedQueryParams}
75+
* puts the ADR-0112 body on `res` itself, which is right for the handlers in
76+
* this package; a dispatcher domain RETURNS `{ handled, response }` and every
77+
* error body on that surface is built by `@objectstack/runtime`'s
78+
* `buildApiError`, whose envelope carries the `success` / `httpStatus` siblings
79+
* this one does not — measured on #17672: the body written below fails that
80+
* surface's `BaseResponseSchema` with `success is missing, must be a boolean`.
81+
* So the message is the portable half and the gate is not: ⛔ a dispatcher
82+
* domain calls {@link repeatedQueryParamMessage}, never this gate.
6483
*/
6584

6685
/**

packages/runtime/src/domains/packages-get-version-scope.test.ts

Lines changed: 121 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,28 @@
3232
*
3333
* §3 pins the two requests that mean "the installed row" — no parameter and
3434
* `?version=latest` — as ONE request, which is the contract the deleted
35-
* handler published (`requested.value || 'latest'`). §4 pins the repeated
36-
* parameter as not-a-silent-success: `?version=a&version=b` carries two
37-
* conflicting intents, and the answer names what it saw rather than choosing.
38-
* ⚠️ §4 asserts the DEFECT CLASS is closed (no `200` with the installed row),
39-
* deliberately not the exact status, because the repo's one rule for a repeated
40-
* single-valued parameter answers `400 VALIDATION_ERROR` and is unreachable
41-
* from this package today — see `readRequestedVersion`'s header in
42-
* `packages.ts`. So this pin stays green when that rule lands here.
35+
* handler published (`requested.value || 'latest'`).
36+
*
37+
* §4 pins the repeated parameter. ⚠️ [#17672] **This section's pin was changed
38+
* deliberately.** As written for #17416 it asserted only that the DEFECT CLASS
39+
* was closed (`status` is not `200`, no installed row rides out) and explicitly
40+
* NOT the status, because the repo's one rule for a repeated single-valued
41+
* parameter answers `400 VALIDATION_ERROR` and was then reachable from nowhere
42+
* outside `@objectstack/rest` — so the door shipped a `404` and this pin was
43+
* written loose enough to survive the eventual fix. ⛔ That `404` was never
44+
* this door's contract: it was the interim answer of an unreachable rule, and
45+
* #17672 filed it because it made a request-shape error indistinguishable from
46+
* the two genuine not-founds §1 pins. The rule is reachable now
47+
* (`@objectstack/rest` publishes `repeatedQueryParamMessage`), so §4 pins the
48+
* END state — the status, the `VALIDATION_ERROR` code, the ADR-0112 nested
49+
* body, and the message BY DERIVATION from the shared function rather than as a
50+
* literal, so a caller is told the same sentence here as on every other door
51+
* that carries the rule.
52+
*
53+
* §5 is the card's actual acceptance criterion, which neither §1 nor §4 states
54+
* on its own: the three refusals this door can give are mutually
55+
* distinguishable by `status` + `error.code`, so a client branching on them can
56+
* finally tell "your request named the parameter twice" from "not found".
4357
*
4458
* ## The harness
4559
*
@@ -51,6 +65,13 @@
5165

5266
import { describe, it, expect } from 'vitest';
5367
import { SchemaRegistry } from '@objectstack/objectql';
68+
// [#17672] The SHARED rule's message, from the module that owns it. §4 asserts
69+
// the wire text by DERIVATION from this function — ⛔ never as a literal, which
70+
// would go on passing while the door answered a sentence of its own that
71+
// happened to match the day it was written. The control that makes the
72+
// derivation falsifiable is the ablation recorded in the PR: change the
73+
// sentence here and this door's answer moves with it.
74+
import { repeatedQueryParamMessage } from '@objectstack/rest';
5475
import { HttpDispatcher } from '../http-dispatcher.js';
5576

5677
const PKG = 'com.acme.crm';
@@ -121,6 +142,9 @@ describe('#17416 GET /packages/:id — ?version= scopes the read', () => {
121142

122143
// The discriminating field, not the status alone.
123144
expect(scoped.status).toBe(404);
145+
// [#17672] A GENUINE not-found, and it stays one: this is the half
146+
// of the card that must SURVIVE the repeated-parameter fix.
147+
expect(scoped.body?.error?.code).toBe('RESOURCE_NOT_FOUND');
124148
expect(scoped.body?.error?.message).toContain(ABSENT);
125149
expect(scoped.body?.error?.message).toContain(INSTALLED);
126150
// ⛔ No package row rode out on the refusal.
@@ -147,6 +171,26 @@ describe('#17416 GET /packages/:id — ?version= scopes the read', () => {
147171
// ⛔ The id 404 is NOT re-worded by the version scope: a package that
148172
// is not here cannot be "at the wrong version".
149173
expect(r.response?.body?.error?.message).toBe(`Package 'com.absent.pkg' not found`);
174+
// [#17672] The second genuine not-found, pinned on its code too —
175+
// and this pair is a PUBLISHED sentence, not only an internal one:
176+
// `content/docs/kernel/contracts/metadata-service.mdx`'s route table
177+
// says of `GET /api/v1/packages/:id` that «a missing id answers
178+
// `404 RESOURCE_NOT_FOUND`, message `Package 'ID' not found`». Both
179+
// halves are asserted here, so moving either turns this red instead
180+
// of silently falsifying that page.
181+
expect(r.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND');
182+
});
183+
184+
it('an unknown id wins over a repeated ?version= — the #17416 ordering, unchanged', async () => {
185+
// [#17672] The multiplicity check sits AFTER the id lookup, where
186+
// #17416 put the version scope. This card moved the STATUS of a
187+
// refusal, ⛔ not the order of two refusals — so an id this registry
188+
// does not hold keeps answering `not found` with a repeated
189+
// parameter riding along, and that is pinned rather than incidental.
190+
const dispatcher = make();
191+
const r = await dispatcher.handlePackages('/com.absent.pkg', 'GET', undefined, { version: ['a', 'b'] }, reader());
192+
expect(r.response?.status).toBe(404);
193+
expect(r.response?.body?.error?.message).toBe(`Package 'com.absent.pkg' not found`);
150194
});
151195
});
152196

@@ -180,14 +224,47 @@ describe('#17416 GET /packages/:id — ?version= scopes the read', () => {
180224
});
181225
});
182226

183-
describe('§4 a repeated ?version= is not resolved silently', () => {
227+
describe('§4 a repeated ?version= is refused 400 VALIDATION_ERROR, in the shared rule’s words', () => {
184228
it('two conflicting values are not answered 200 with the installed row', async () => {
185229
const r = await get({ version: [ABSENT, INSTALLED] });
186-
// The defect class: a success carrying a row the caller did not ask for.
230+
// The defect class #17416 closed: a success carrying a row the
231+
// caller did not ask for. Kept as its own assertion — the status
232+
// pin below is a stronger claim, and this one is the reason.
187233
expect(r.status).not.toBe(200);
188234
expect(r.body?.data).toBeUndefined();
189-
// It says what it saw rather than choosing one of the two.
190-
expect(r.body?.error?.message).toContain('supplied 2 times');
235+
});
236+
237+
it('[#17672] answers 400 VALIDATION_ERROR — a request-shape error, not a not-found', async () => {
238+
const r = await get({ version: [ABSENT, INSTALLED] });
239+
// ⚠️ The interim answer was `404`. See this file's header: that was
240+
// the answer of an unreachable rule, never this door's contract.
241+
expect(r.status).toBe(400);
242+
// ADR-0112 NESTED body, and the standard catalog's member for 400 —
243+
// derived by `buildApiError` from the status, so nothing in
244+
// `packages/spec` moved for it. It is also the answer a PUBLISHED
245+
// page already documented for this exact condition:
246+
// `content/docs/api/client-sdk.mdx`'s error table gives
247+
// `VALIDATION_ERROR` / 400 for «The request was refused before any
248+
// record was validated — a repeated query parameter, …». This door
249+
// contradicted that page for as long as it answered `404`.
250+
expect(r.body?.error?.code).toBe('VALIDATION_ERROR');
251+
expect(r.body?.error?.httpStatus).toBe(400);
252+
expect(r.body?.success).toBe(false);
253+
expect(r.body?.data).toBeUndefined();
254+
});
255+
256+
it('[#17672] the sentence is the SHARED one, by derivation — not a local copy that matches', async () => {
257+
const r = await get({ version: [ABSENT, INSTALLED] });
258+
// ⛔ Not a literal. This is the whole point of the card: one rule,
259+
// one message. Computed from `@objectstack/rest`'s function, so the
260+
// day that sentence changes, this door's answer changes with it —
261+
// and a door that grew a second sentence of its own turns this red.
262+
expect(r.body?.error?.message).toBe(repeatedQueryParamMessage('version', 2));
263+
// The count is the door's own reading, not a constant in the
264+
// message: three occurrences say three.
265+
const three = await get({ version: ['a', 'b', 'c'] });
266+
expect(three.body?.error?.message).toBe(repeatedQueryParamMessage('version', 3));
267+
expect(three.status).toBe(400);
191268
});
192269

193270
it('ONE occurrence encoded as a one-element array is one occurrence', async () => {
@@ -202,6 +279,38 @@ describe('#17416 GET /packages/:id — ?version= scopes the read', () => {
202279
const r = await get({ version: [ABSENT] });
203280
expect(r.status).toBe(404);
204281
expect(r.body?.error?.message).toContain(ABSENT);
282+
// [#17672] Still a genuine not-found — the unwrapping rule means
283+
// one occurrence is one occurrence, so this is NOT a shape error.
284+
expect(r.body?.error?.code).toBe('RESOURCE_NOT_FOUND');
285+
});
286+
});
287+
288+
describe('§5 [#17672] the three refusals are mutually distinguishable', () => {
289+
it('a client branching on status + code can tell shape-error from not-found', async () => {
290+
// The card's acceptance criterion, stated as one reading. Before
291+
// this fix all three were `404` / `RESOURCE_NOT_FOUND` — the whole
292+
// defect, and the reason §1's two pins alone did not catch it.
293+
const host = make();
294+
const repeated = await read(host, { version: [ABSENT, INSTALLED] });
295+
const wrongVersion = await read(host, { version: ABSENT });
296+
const unknownId = await (async () => {
297+
const r = await host.handlePackages('/com.absent.pkg', 'GET', undefined, {}, reader());
298+
return { status: r.response?.status ?? 200, body: r.response?.body };
299+
})();
300+
301+
const seen = [repeated, wrongVersion, unknownId]
302+
.map((r) => `${r.status} ${r.body?.error?.code}`);
303+
expect(seen).toEqual([
304+
'400 VALIDATION_ERROR',
305+
'404 RESOURCE_NOT_FOUND',
306+
'404 RESOURCE_NOT_FOUND',
307+
]);
308+
// The request-shape error is separated from BOTH not-founds, which
309+
// is the distinction the card asked for. The two not-founds remain
310+
// one class on purpose — they differ by message, and §1 pins that.
311+
expect(seen[0]).not.toBe(seen[1]);
312+
expect(seen[0]).not.toBe(seen[2]);
313+
expect(wrongVersion.body?.error?.message).not.toBe(unknownId.body?.error?.message);
205314
});
206315
});
207316
});

0 commit comments

Comments
 (0)