Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/share-door-user-message.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/rest": patch
---

fix(rest): the record-share family carries a producer-marked `userMessage` (#12669, fork (a))

`GET`/`POST /api/v1/data/:object/:id/shares` and `DELETE …/shares/:shareId` now
put a producer's own caller-facing refusal text on the wire's
`error.userMessage`. Previously that sentence was classified and then dropped at
the re-dress: `respondSharingError` (`packages/rest/src/rest-server.ts`) asks
`classifiedRefusalAnswer` — the flat `/data` door's own classification, which
attaches the mark in `withDeclaredUserMessage` (#9934) — and forwarded only
`status`, `code`, the message and, since #12510, `declaredCode` into the nested
ADR-0112 D5 envelope.

Nothing invalid shipped, which is what made the loss silent and one-directional:
every body parsed as `ApiErrorSchema`, while a console told by ADR-0112 to render
`userMessage` verbatim found nothing at this door and fell back to its generic
substitution — for the same throw the twin door rendered. Measured before the
repair, one producer through both real routes: a thrown `{ code:
'CLOSE_PERIOD_LOCKED', status: 409, userMessage: 'Ask finance to reopen the
period.' }` answered `409 RESOURCE_CONFLICT` at both doors, with the sentence at
`/data` and nothing at the share door.

The population is wider than its `declaredCode` neighbour's and the two are
deliberately not symmetric. `declaredCode` is read from the classification
because presence there MEANS demotion, an invariant a caller would otherwise
re-derive (#12510). `userMessage` has no such invariant: `declaredUserMessage`
already decided presence — a non-empty string, or nothing — and
`truncateClientMessage` already applied #5423's bound, so the classification's
own field is carried straight through. A REGISTERED code demotes nothing and so
carries no `declaredCode`, and still carries its author's sentence.

Additive and shape-preserving. An unmarked refusal, an empty or whitespace-only
mark and a non-string one still carry no key; `status`, `code` and `message` are
byte-identical to before on every existing path; the five-prefix ADR-0111 idiom
and the family's own `SHARES_LIST_FAILED` / `SHARE_GRANT_FAILED` /
`SHARE_REVOKE_FAILED` 500 terminal are untouched. `ApiErrorSchema` has declared
the field as optional since #9934, so the contract's accept set does not move.

Fork (b) of #12669 — mapping the flat dialect's top-level `issues` onto the
nested envelope's `ApiError.details` — is a shape decision on a contract field
and is deliberately not shipped here; #12669 stays open on it.
45 changes: 44 additions & 1 deletion packages/rest/src/rest-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9705,9 +9705,52 @@ export class RestServer {
const declaredCode = typeof refusal.body.declaredCode === 'string'
? refusal.body.declaredCode
: undefined;
// [#12669] …and so does the sentence the producer addressed to
// the CALLER. The flat `/data` door attaches it in
// `withDeclaredUserMessage` (`error-response.ts`, #9934) and
// the one classification asked above is already holding the
// result; this family dropped it at the same re-dress, with the
// same one-directional silence — an author's own remedy text
// reaching `/data` and vanishing here.
//
// ⛔ `userMessage` and `declaredCode` are NOT symmetric, and
// the next reader will assume they are because they arrive on
// the same line. `declaredCode` is read from the CLASSIFICATION
// because presence there MEANS demotion — an invariant the raw
// thrown field does not carry, which is the whole subject of
// the paragraphs above (⛔ not restated here: #12510 owns that
// derivation and one reason with two copies is this lane's own
// recurring defect). `userMessage` has NO invariant left for a
// caller to re-derive. `declaredUserMessage` already decided
// PRESENCE — the field exists on an error only because an
// author deliberately wrote caller-facing text onto it, and
// platform and driver code never set it — and
// `truncateClientMessage` already applied #5423's bound to the
// value. Reading `refusal.body.userMessage` IS the rule; there
// is no second function to run it through, and running one
// would be a second answer to a question the classification
// has already answered.
//
// The `typeof` guard below is therefore not that invariant. It
// is the same non-string floor `code` and `declaredCode` carry
// two lines up: a producer may put anything on a thrown object,
// and a number arriving in a string channel is the #3842 drift.
//
// ⛔ SCOPE: `userMessage` only. The producer's structured
// context — the flat body's top-level `issues` — is the other
// half of #12669 and is deliberately NOT forwarded here. The
// nested envelope's channel for it is `ApiError.details`, so
// mapping one onto the other is a SHAPE decision on a contract
// field rather than a rename, and #12669 is open on it.
const userMessage = typeof refusal.body.userMessage === 'string'
? refusal.body.userMessage
: undefined;
respondError(
res, refusal.status, code, String(refusal.body.error ?? ''),
declaredCode !== undefined ? { declaredCode } : undefined,
{
...(declaredCode !== undefined ? { declaredCode } : {}),
...(userMessage !== undefined ? { userMessage } : {}),
},
);
return true;
}
Expand Down
Loading
Loading