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
40 changes: 40 additions & 0 deletions .changeset/share-door-declared-code.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/rest": patch
---

fix(rest): the record-share family carries a demoted producer code on `declaredCode` (#12510)

`GET`/`POST /api/v1/data/:object/:id/shares` and `DELETE …/shares/:shareId` now
put a producer's own error-code spelling on the wire's `error.declaredCode` when
the closed ADR-0112 vocabulary did not admit it. Previously that spelling was
resolved and then dropped at the re-dress: `respondSharingError`
(`packages/rest/src/rest-server.ts`) asks `classifiedRefusalAnswer` — the flat
`/data` door's own classification, which already carries the demoted string —
and forwarded only `status`, `code` and the message into the nested ADR-0112 D5
envelope.

Nothing invalid shipped, which is what made the loss silent and one-directional:
the closed `code` still carried the member the HTTP status derives, so every
body parsed, while an author's spelling vanished and a consumer told by ADR-0112
to read `declaredCode` found nothing there. Measured before the repair, one
producer through both doors: a thrown `{ code: 'CLOSE_PERIOD_LOCKED', status:
409 }` answered `409 RESOURCE_CONFLICT` at both, with `declaredCode:
'CLOSE_PERIOD_LOCKED'` at `/data` and nothing at the share door.

This ADOPTS the rule the sibling doors already apply rather than inventing one.
The demote is `demotedDeclaredCode`'s answer — the single definition of
"presence means demotion" — reached here through the classification's own
`declaredCode`, which the flat door computes with exactly that function
(`thrownCodeFields`, `packages/rest/src/error-response.ts`, #9232). The pair is
carried, not recomputed: this door asks the classification once and re-dresses
that one answer, as it already does for `status`, `code` and the message.

Additive and shape-preserving. A REGISTERED producer code still carries no
`declaredCode` (repeating it would put two spellings of one fact on every
refusal), a producer that declared no code still carries none, a non-string
`code` is still context rather than a wire spelling, and `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 #9106, so the
contract's accept set does not move.
65 changes: 56 additions & 9 deletions packages/rest/src/rest-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9581,7 +9581,14 @@ export class RestServer {
status: number,
code: ErrorCode,
message: string,
): void => sendEnvelopeError(res, status, code, message);
// [#12510] The shared writer's OWN `extra` type, referenced rather
// than restated: this wrapper decides POSITION (the nested D5
// envelope), never which channels exist. A local `{ declaredCode?:
// string }` would be a second, narrower declaration of a set
// `sendError` already owns — the shape that silently stops
// forwarding the next channel admitted there.
extra?: Parameters<typeof sendEnvelopeError>[4],
): void => sendEnvelopeError(res, status, code, message, extra);

const respond501 = (res: any) => respondError(
res, 501, 'NOT_IMPLEMENTED',
Expand DownExpand Up@@ -9651,17 +9658,57 @@ export class RestServer {
// is the one place the two dialects genuinely differ: the flat
// body may omit `code`, the nested one may not.
//
// ⚠️ Measured and NOT repaired here: an UNREGISTERED producer
// code is demoted by the shared resolver to a `declaredCode`
// sibling (ADR-0112 #9232), and `sendError`'s `extra` does not
// accept that field — so the author's own spelling is dropped
// on this family while `/data` carries it. Widening the shared
// envelope writer is a `@objectstack/types` change outside
// this card's surface; filed separately.
// [#12510] …and the producer's OWN spelling travels with it.
// An UNREGISTERED thrown code is demoted by the shared rule to
// a `declaredCode` sibling (ADR-0112 #9232) — the open,
// author-authored channel `ApiErrorSchema` has declared since
// #9106. This family used to drop it: the classification below
// was already holding the demoted string and only `code` and
// the message were re-dressed, so an app's spelling vanished
// here while the flat `/data` door carried it. Nothing invalid
// shipped — the closed `code` still carried the member the
// status derives — which is exactly what made the loss silent
// and one-directional: a consumer told by ADR-0112 to read
// `declaredCode` found nothing at this door.
//
// ⛔ The reason that used to stand here said `sendError`'s
// `extra` would not accept the field. That was true when it was
// written and false since #11719 / `db8c288` (PR #12403) added
// `declaredCode` to the writer's `Pick`. A stale sentence that
// discourages a repair costs more than one that misdescribes a
// mechanism, so it is recorded rather than merely deleted.
//
// ⛔ Read the CLASSIFICATION's field, never the resolver's raw
// `thrown.declaredCode`. Presence MEANS demotion
// (`ApiErrorSchema.declaredCode`'s documented invariant) and
// the raw field is set for a REGISTERED spelling too —
// measured: a producer throwing `{ code: 'RECORD_LOCKED',
// status: 409 }` resolves with `declaredCode: 'RECORD_LOCKED'`
// sitting beside an identical `code`, and forwarding that would
// put two spellings of one fact on every registered refusal.
// `refusal.body.declaredCode` is the answer AFTER
// `demotedDeclaredCode` (`error-response.ts`'s
// `thrownCodeFields`, the same one definition the dispatcher
// door reads), so the invariant arrives with the value.
//
// ⭐ Why re-dress rather than re-resolve: this door asks
// {@link classifiedRefusalAnswer} ONCE and re-dresses that one
// answer, exactly as it does for `status`, `code` and the
// message. Calling the resolver a second time here would be a
// second answer to a question already asked — the shape that
// let two `/api/v1/packages` doors drift apart (#12405). The
// pair is carried, not recomputed: `code` and `declaredCode`
// leave this door as the pair `thrownCodeFields` produced.
const code = typeof refusal.body.code === 'string'
? refusal.body.code as ErrorCode
: standardErrorCodeForHttpStatus(refusal.status);
respondError(res, refusal.status, code, String(refusal.body.error ?? ''));
const declaredCode = typeof refusal.body.declaredCode === 'string'
? refusal.body.declaredCode
: undefined;
respondError(
res, refusal.status, code, String(refusal.body.error ?? ''),
declaredCode !== undefined ? { declaredCode } : undefined,
);
return true;
}
const msg = String(error?.message ?? error ?? '');
Expand Down
Loading
Loading