Commit 2b08a72
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
- .changeset
- packages
- rest/src
- runtime/src/domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
58 | 58 | | |
59 | 59 | | |
60 | 60 | | |
61 | | - | |
62 | | - | |
63 | | - | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
64 | 83 | | |
65 | 84 | | |
66 | 85 | | |
| |||
Lines changed: 121 additions & 12 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
36 | | - | |
37 | | - | |
38 | | - | |
39 | | - | |
40 | | - | |
41 | | - | |
42 | | - | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
43 | 57 | | |
44 | 58 | | |
45 | 59 | | |
| |||
51 | 65 | | |
52 | 66 | | |
53 | 67 | | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
54 | 75 | | |
55 | 76 | | |
56 | 77 | | |
| |||
121 | 142 | | |
122 | 143 | | |
123 | 144 | | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
124 | 148 | | |
125 | 149 | | |
126 | 150 | | |
| |||
147 | 171 | | |
148 | 172 | | |
149 | 173 | | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
150 | 194 | | |
151 | 195 | | |
152 | 196 | | |
| |||
180 | 224 | | |
181 | 225 | | |
182 | 226 | | |
183 | | - | |
| 227 | + | |
184 | 228 | | |
185 | 229 | | |
186 | | - | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
187 | 233 | | |
188 | 234 | | |
189 | | - | |
190 | | - | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
191 | 268 | | |
192 | 269 | | |
193 | 270 | | |
| |||
202 | 279 | | |
203 | 280 | | |
204 | 281 | | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
205 | 314 | | |
206 | 315 | | |
207 | 316 | | |
0 commit comments