Found while measuring #8502's population on the real stack. Unassigned; nobody is on it. Not a defect in #8502's PR — that card is the message limb only, and this is the httpStatus limb beside it, deliberately not ridden in.
The measurement
toRowApiError (packages/metadata-protocol/src/protocol.ts) sets httpStatus from one field:
conststatus=typeoferr?.status==='number' ? err.status : undefined;
...
...(status!==undefined ? {httpStatus: status} : {}),Driven on the real stack — a real ObjectQL over a real SqlDriver, through all three bulk-write loops — two producers that reach these catches declare a client refusal without.status:
| producer | code | .status | .statusCode | validation shape | row's httpStatus |
|---|
objectql ValidationError | VALIDATION_FAILED | — | — | yes | absent |
| plugin-approvals record lock | RECORD_LOCKED | — | 409 | no | absent |
Measured rows, verbatim:
{ "code": "VALIDATION_FAILED", "message": "name must be ≤ 4 characters (got 15)" }
{ "code": "RECORD_LOCKED", "message": "RECORD_LOCKED: record 'ok1' of 'm8502_task' is locked while an approval is in progress" }Both are well-defined client refusals — 400 and 409 respectively — and a caller branching on httpStatus to tell "fix your input" from "the server broke" gets nothing for either. The sibling rows in the same response do carry it (rowRequiredIdError → 400, recordNotFoundError → 404), so within one batch the field is present for some failure rows and absent for others with no signal saying which.
resolveThrownHttpError (@objectstack/types) already answers this exact question and reads all three declarations — it is what the HTTP doors use, and what #8502 routed the message limb through. The httpStatus limb is the only one of the row's three fields still reading a single spelling:
Why it was left out of #8502
Adding httpStatus where the wire did not previously carry one is an addition to the response, not a withhold — the same reasoning #8441 recorded for declining to mint a code where none existed. It wants its own decision about whether BatchOperationResult consumers can absorb a newly-present field, and possibly a note in the ApiError contract about what absence means. So it is filed rather than ridden in.
Not a disclosure issue, unlike its two siblings — this is a usability/contract gap.
Related
#8502 (the message limb, PR #8569) · #8441 (the code limb) · #7525 (the same single-spelling defect at the HTTP door, fixed there) · ADR-0112
Generated by Claude Code
Found while measuring #8502's population on the real stack. Unassigned; nobody is on it. Not a defect in #8502's PR — that card is the
messagelimb only, and this is thehttpStatuslimb beside it, deliberately not ridden in.The measurement
toRowApiError(packages/metadata-protocol/src/protocol.ts) setshttpStatusfrom one field:Driven on the real stack — a real
ObjectQLover a realSqlDriver, through all three bulk-write loops — two producers that reach these catches declare a client refusal without.status:code.status.statusCodehttpStatusValidationErrorVALIDATION_FAILEDRECORD_LOCKEDMeasured rows, verbatim:
{ "code": "VALIDATION_FAILED", "message": "name must be ≤ 4 characters (got 15)" } { "code": "RECORD_LOCKED", "message": "RECORD_LOCKED: record 'ok1' of 'm8502_task' is locked while an approval is in progress" }Both are well-defined client refusals — 400 and 409 respectively — and a caller branching on
httpStatusto tell "fix your input" from "the server broke" gets nothing for either. The sibling rows in the same response do carry it (rowRequiredIdError→ 400,recordNotFoundError→ 404), so within one batch the field is present for some failure rows and absent for others with no signal saying which.resolveThrownHttpError(@objectstack/types) already answers this exact question and reads all three declarations — it is what the HTTP doors use, and what #8502 routed themessagelimb through. ThehttpStatuslimb is the only one of the row's three fields still reading a single spelling:code— catalog membership (A raw drivercode(SQLITE_ERROR) still reaches client-facing payloads through the batch verbs'codelimb — the sibling field #8333 did not cover #8441)message— the boundary resolver's verdict (deleteManyData's row errors carry raw driver text inerrors[].message— the data-batch producer #8136/#8333 never reached #8502)httpStatus—.statusonly ⟵ this issueWhy it was left out of #8502
Adding
httpStatuswhere the wire did not previously carry one is an addition to the response, not a withhold — the same reasoning #8441 recorded for declining to mint acodewhere none existed. It wants its own decision about whetherBatchOperationResultconsumers can absorb a newly-present field, and possibly a note in theApiErrorcontract about what absence means. So it is filed rather than ridden in.Not a disclosure issue, unlike its two siblings — this is a usability/contract gap.
Related
#8502 (the
messagelimb, PR #8569) · #8441 (thecodelimb) · #7525 (the same single-spelling defect at the HTTP door, fixed there) · ADR-0112Generated by Claude Code