fix(rest): the generic declared-status passthrough names its object on both error doors - #15452
fix(rest): the generic declared-status passthrough names its object on both error doors#15452os-litant wants to merge 5 commits into
Conversation
…n both doors (#14725) `resolveErrorResponse`'s 4xx arm now ends on the same `...(object ? { object } : {})` limb `classifyDataError`'s generic declared-status passthrough has always carried, so the two copies of one passthrough no longer differ by a key. Measured on `main` @ `a12b15e394`, one error object, both doors: { code: 'DUPLICATE_RECORD', status: 409 } // no `name`, so no bespoke arm mapDataError(err, 'duly_note') 409 {"error":"…","code":"DUPLICATE_RECORD","object":"duly_note"} sendThrownError(res, err, 'duly_note') 409 {"error":"…","code":"DUPLICATE_RECORD"} The 5xx arm deliberately gains nothing: its sibling `declaredServerFaultAnswer` names no object either, so that band already agreed and adding the limb there would create the divergence this one removes. The message-text sniff is not lifted above the passthrough. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
`error-response-structured-arm-door-parity.test.ts` §4's `ERR_DATASOURCE_UNAVAILABLE` case pinned the bodies differing by one key and named this card as the owner of that residue. With the limb added the case's own CONVERGED label holds for the body too, so the pin asserts door-to-door body equality instead of describing a gap that is closed. The §5 drift guard's `RECORD_NOT_FOUND` entry moves from `known-gap` (citing this card) to `by-design` for the same reason: the arm still stays out of the shared classification — its second limb is a message-TEXT gate, outside the declared-code boundary — but it no longer needs lifting, because both limbs now converge measurably. Leaving a `known-gap` citing a closed card would leave the guard green by disclosing a gap that no longer exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…clared-status-passthrough-object-key
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e02c2a8e63dc7184d2d422457fe064ef9b9a2351 && git checkout e02c2a8e63dc7184d2d422457fe064ef9b9a2351
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6ed4b811af30080854eb94c6bb292e1bd137d356 e141d14adbfcf8659c17c97f113018e44ca981a8 && git checkout -B drift-repro 6ed4b811af30080854eb94c6bb292e1bd137d356 && git merge --no-ff e141d14adbfcf8659c17c97f113018e44ca981a8
node scripts/docs-audit/affected-docs.mjs --json 6ed4b811af30080854eb94c6bb292e1bd137d356
|
… of a published surface The bump rule lives in `.github/workflows/pr-automation.yml`'s Check Changeset prose (and is cited from `scripts/check-changeset-no-major.mjs`), not in AGENTS.md: "A purely additive widening of a published package's public surface (a new exported symbol on an `index`, a new accepted key or value) takes at least `minor`. The commit type may raise a bump but never lower it below what the act requires." Maintainer ruling 2026-09-04, decision batch #35. This PR adds a new accepted key to a published 4xx body, so the act is that widening whatever the `fix(` type says. The historical `patch` precedents this changeset previously cited are named in the same ruling as pre-rule and are deliberately not retro-fixed, so they cannot settle it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…clared-status-passthrough-object-key
|
Bump raised to Head is now Verification at the new head: the derived gate family (46 commands, all green — it grew
Generated by Claude Code |
Fixes #14725
resolveErrorResponse's 4xx arm now ends on the same...(object ? { object } : {})limb
classifyDataError's generic declared-status passthrough has always carried, sothe two copies of one passthrough no longer differ by a key. Direction ruled by triage
(comment 5518039240): option yes, following #14541's precedent.
1. Re-measured on today's
mainbefore writing anythingThe card's disagreement table was measured on the #14541 branch, so the first
question was whether there is still a card. There is. Both doors, one error object,
origin/main@a12b15e394(mapDataError= the single-record/datadoor,sendThrownError= the bulk / metadata / UI door):{ code: 'DUPLICATE_RECORD', status: 409 }(noname, so no bespoke arm)409 {"error":"…","code":"DUPLICATE_RECORD","object":"duly_note"}409 {"error":"…","code":"DUPLICATE_RECORD"}{ code: 'RECORD_LOCKED', status: 409 }…,"object":"duly_note"}…}recordNotFoundError('duly_note','abc')(code+status:404+object)404 {…,"code":"RECORD_NOT_FOUND","object":"duly_note"}404 {…,"code":"RECORD_NOT_FOUND"}404 {…,"object":"duly_note"}OBJECT_NOT_FOUND(bespoke arm, #14541)404 {…,"object":"duly_note"}After the change all five agree, including the two controls, which stay byte-identical.
Residue 2's producer chain verified at the throw site:
recordNotFoundError(
packages/core/src/utils/record-not-found.ts) setscode = 'RECORD_NOT_FOUND',status = 404andobject— so its declared status carries it past theRECORD_NOT_FOUNDarm into this same generic passthrough. That is why one limb closesboth residues, and why no arm needed lifting.
2. The census the contract review needs to price this
Method. The affected door is
resolveErrorResponse. Its only callers are the twoexported senders in
error-response.ts—sendThrownErrorandhandleRouteError—plus
classifiedRefusalAnswer, which calls it with noobjectargument at all.Neither sender is re-exported from
packages/rest/src/index.ts, so the call-site set isclosed inside this package. Enumerated over every tracked non-test
.tsfile(
git ls-files), excluding comment lines,error-response.ts's own internaldelegations, and
package-routes.ts's localsendThrownError(a different functionof the same name, declared at
package-routes.ts:273, which answers the nested envelopethrough
resolveThrownHttpErrorand never reaches this door). Each site's route wasresolved by walking back to its nearest
path:/method:declaration.Result — 35 route call sites; 9 of them can widen a body:
req.params?.object(6) orString(req.params?.object || '')(3)object''(falsy)The 9 widened routes:
POST /data/:object/batch,/createMany,/updateMany,/deleteMany,POST /data/:object/:id/clone,POST /data/:object/import,POST /data/:object/import/jobs,GET /data/:object/export,GET /ui/view/:object/:type. The 5 literal-''sites are the/data/import/jobs/*family; the 21 bare sites are the metadata / UI / discovery /docs / cross-object-batch families. So the card's "bounded above by the data routes" is
now exact: 9 of 35, and the name is read from the door's argument, never from
error.object— pinned, so a future edit cannot quietly promote the producer's field.3. Census of the passthrough itself — is there a third copy?
There is not.
declaredHttpStatushas four call sites inerror-response.ts(
declaredServerFaultAnswer,classifyDataError's sandbox-unwrap door,classifyDataError's generic passthrough,classifiedRefusalAnswer) plus one inline400-599 band read in
resolveErrorResponse. Of those, exactly two are the genericdeclared-status passthrough — the two the card names. Repo-wide, the only other
...(object ? { object } : {})sites outsideerror-response.tsarerest-server.ts's hand-builtBATCH_TOO_LARGE400 andruntime'spermission-denied-envelope.ts; neither is a status passthrough.4. Boundaries kept — and pinned, not described
/^Record \S+ not found in \S+/i) was not liftedabove the passthrough. Pinned two ways, because either alone can be green for the
wrong reason: behaviourally (a sniff-matching message declaring
409+RECORD_LOCKEDanswers 409RECORD_LOCKEDon both doors — if the sniff had movedinto
structuredCodeAnswerit would answer 404RECORD_NOT_FOUND), andpositionally (the literal still sits inside
classifyDataError, never in theshared classification).
declaredServerFaultAnswernames no object either, so the two doors already agreed there — adding the limb would
have created a divergence, on top of putting a caller-supplied name into a body
whose whole rule (sendError 的显式状态直通覆盖 400–599,5xx 的原始驱动报错绕过全部泄漏启发式直达客户端(metadata-protocol 有活体产出方) #5437 / mapDataError 的显式状态直通只覆盖 4xx,数据路由上一个声明了 502/503 的生产者拿不回自己的状态码(与 resolveErrorResponse 不对等) #5582) is that a declared server fault says nothing beyond
status and code. Pinned.
classifiedRefusalAnswer's families (analytics dataset face, record-share) areunmoved: that entry point passes no object. Their key sets are pinned at KEY level.
rest-server.tsuntouched ([finding] the #13904 engine-slot repair is re-collapsed atobjectQLProvider's SECOND consumer —GET /meta/object/:name/state/:fieldanswers 404 for a wired-and-failing engine #15405 is an open ungraded finding on its consumersites — not this card's).
5. Fixture triage — two existing pins had to be re-judged, and why
Both are in
error-response-structured-arm-door-parity.test.ts, #14541's own file.ERR_DATASOURCE_UNAVAILABLEcase went red, exactly as it should: itasserted
expect(bulk.body).not.toHaveProperty('object')under a comment namingthis card as the owner of that residue and pinning it "so the residue is visible
rather than implied". The case is labelled CONVERGED, and §4's own contract says
CONVERGED means "both doors answer the same status AND body". With the limb added
that label now holds for the body too, so the assertion became
expect(bulk.body).toEqual(single.body).RECORD_NOT_FOUNDmoved fromknown-gaptoby-design. It is the guard Routes exiting throughhandleRouteErroranswer the engine'sDUPLICATE_RECORDenvelope fromresolveErrorResponse's.statuspassthrough —fieldandobjectdropped,mapDataError's structured 409 arms never consulted #14541's contract review condition 3 required, and itwas green by disclosure of a gap that this PR closes. Leaving it would leave the
guard green by disclosing a gap that no longer exists, citing a card that is closed —
the exact "stale cover" the guard's own third test exists to prevent. The arm still
stays out of the shared classification (its second limb is a message-TEXT gate,
outside the declared-code boundary — unchanged), but it no longer needs lifting:
both limbs now converge measurably, and the new entry says so and names the file that
pins it. No entry was removed and the allowlist did not grow.
Recorded and not repaired here: with no
known-gapentries left, that guard'sif (entry.kind === 'known-gap') expect(typeof entry.card).toBe('number')limb nolonger evaluates. It is a validation rule for a list expected to gain entries, not a
check that lost its subject — flagged rather than silently worked around.
6. Tests
New:
packages/rest/src/error-response-generic-passthrough-object-parity.test.ts— 17cases (
Tests 17 passed (17)on its own). The pins are the two doors agreeing (samestatus, same body), never the limb existing, so a refactor that keeps the limb and moves
the agreement still goes red. Every refusal assertion states
codeandstatus(ADR-0112); no bare
toThrow(). §5 asserts key absence ('object' in body === false)rather than relying on
toEqual, which cannot tell{}from{ object: undefined }—and emitting
"object": undefinedis a different published body from omitting the key.Ablation, both legs proved on disk before the run (see the Verification section for
the exact confirmations). The test imports
./error-response.jsrelatively, inside itsown package, so vitest resolves it to the source — no
distis involved and the redproves the run read the mutated bytes.
7. Changeset —
minor, by a rule I first failed to findminoron@objectstack/rest. I originally shippedpatchand flagged the question,because I could not find the "additive widening is at least minor" rule in
AGENTS.mdor
.claude/skills/pm-dispatch/references. It is not in either — it lives in.github/workflows/pr-automation.yml(the Check Changeset step's "WHICH LEVEL" prose,i.e. the text an author who is told a changeset is missing actually reads), and is cited
from
scripts/check-changeset-no-major.mjsso that declared and enforced stay in oneplace. Landed 2026-09-04, maintainer decision batch #35 on #15294. Read at
origin/mainand quoted:
That last sentence settles the argument this section used to make: the precedent I cited
— #14541's own
patchfor a strictly larger instance of this delta — is named by theruling as pre-rule, and the ruling exists precisely because two contract reviews read
that history to opposite bumps for the same additive act. Citing the history cites the
ambiguity the ruling ended.
This PR adds a new accepted key to a published 4xx body on 9 routes, so the act is that
widening whatever the
fix(type says — and it would be incoherent to call the same acta contract widening for the reviewer (§8) and a no-public-surface-change for the
changeset. The bump is the only thing that moved: the diff is otherwise unchanged.
8. Clause-② — re-declared from the DELIVERED diff
Clause-②: yes, unchanged from the claim declaration and mechanical under #14696'sfloor: a new key on a published payload. The delivered diff is narrower than a
reader of the card might expect — one limb, no arm lifted, no status moved, no
codevalue changed, nothing removed — but "narrower" does not lower the clause: the key is
still new on a published 4xx body for 9 routes. ⇒ This PR parks: draft, not flipped,
not armed, not enqueued,
needs:contract-reviewapplied to the card and this PRtogether now that the reviewable increment exists (director ruling 5536344304).
9. Verification — final HEAD
e141d14adb, on a clean treeHeavy runs went through
scripts/pm/os-verify-lock.sh; every verdict below is quotedfrom that script's own VERDICT line or the gate's own output, never from a bare
$?,and every exit code was captured before any pipe.
19b2470401.The bump ruling then arrived, so the changeset's one line changed and
origin/mainwasmerged again (11 commits) to keep the gate derivation off a stale tree — final HEAD
e141d14adb. The code diff is byte-identical between the two, so each row sayswhich HEAD produced it rather than implying one sweep.
Tests.
pnpm --filter @objectstack/rest testTest Files 179 passed (179)·Tests 3040 passed (3040)— run at both HEADs, same numberspnpm --filter @objectstack/rest typecheck(tsc --noEmit+check:test-typecheck)0 file(s) / 0 error(s)— at19b2470401pnpm --filter '...@objectstack/rest' test(prefix = dependents), at19b2470401The
typecheckgreen is measured, not assumed:tsc -p tsconfig.test.json --listFilesnames both edited test files (1 hit each), so the check compiled them rather than
excluding them.
Ablation — two legs, direction predicted first, mutation proved on disk before each
run, restore proved after. Measured at
19b2470401; the mutated file isbyte-identical at
e141d14adb, so it was not re-run. The implementation was committedfirst, so both restore legs point at
HEAD; the script carries atraponEXIT/INT/TERM with an absolute path, and each restore is proved by blob-hash equality
against the
HEADblob plus an emptygit diff HEAD— never by an exit code.e9824e26== HEAD blob,git diff HEADempty63 passed (63)code/statusassertions stay green7 failed | 56 passed. Every failure is a body-agreement assertion (toEqual/toHaveProperty('object', …)); thestatusand.codeassertions on the very same cases all passed, and the boundary case went red only on its body-equality line...{ object })toEqualcannot tell{}from{ object: undefined })4 failed | 59 passed— exactly the four key-set pins: §2's "door supplies NO object", §4'sclassifiedRefusalAnswercase, and both §5 casese9824e26== HEAD blob,git diff HEADempty63 passed (63)Leg 2 is the one worth keeping: it is what makes
"object": undefineda differentpublished body from an omitted key, and nothing in the file caught it before.
The ablation ran against source — the test imports
./error-response.jsrelatively,inside its own package, so vitest resolves it to
error-response.ts; that both legswent red with no rebuild is itself the proof the run read the mutated bytes.
Gates. Derived, not reused:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, on the clean tree at final HEAD, no stale-treewarning: 4 paths, 46 commands — all 46 run, all exit 0.
The family grew from 44 to 46 across the two HEADs, and the growth is not the
changeset edit: derived on my own tree before merging, the list was byte-identical to
the 44. The two new members arrived with
origin/main—packages/lint/scripts/check-reference-carrier-shape.mjsand its--self-test— andboth are green.
Two members return exit 3 (
PREREQUISITE NOT MET— NOT MEASURED, neither pass nor red)on a tree with no
dist:check:dual-build-cjs-loadsandcheck:type-check-debt. Theprerequisite was satisfied at each HEAD with a full
turbo run build --filter='./packages/*' --filter='./packages/*/*'(71/71 successfulboth times) and both re-run: exit 0 (
103/66/619/1against floors90/58/520/1;14 ledger entries re-measured, none above its recorded number).The five artifact-roster families whose roster sits under a directory these paths are in
— silence there is evidence in neither direction — were run at both HEADs:
check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:swallow-census-controls, all exit 0.The
Check Changesetjob's own three base-scoped checks were run directly against thereal merge base (
6ed4b811af), since the derived family only carries their--self-testhalves:check-empty-changeset --base(1 declaring changeset(s) added),check-adr-0087-registration --base(1 non-breaking changeset(s) seen),check-changeset-no-major --base(introduces no major bump) — all exit 0, self-testsincluded. Control-character scan run beyond
check:nul-bytesover every edited file andthis body: no hits.
before reporting rather than after waiting for it.