Skip to content

Commit f206385

Browse files
committed
fix(rest): the record-share family carries a demoted producer code on declaredCode (#12510)
`respondSharingError` asks `classifiedRefusalAnswer` — the flat `/data` door's own classification, which already holds the demoted producer spelling — and re-dressed only `status`, `code` and the message into the nested ADR-0112 D5 envelope. The author's own code was dropped one line below the local that carried it: nothing invalid shipped, since the closed `code` still carried the member the status derives, which is what made the loss silent and one-directional. The pair is carried, not recomputed. `refusal.body.declaredCode` is the shared rule's OUTPUT (`thrownCodeFields` = `resolveThrownHttpError` + `demotedDeclaredCode`), so "presence means demotion" arrives with the value; the resolver's RAW `declaredCode` is deliberately not read, because it is set for a REGISTERED spelling too and forwarding it would put two spellings of one fact on every registered refusal. The comment that explained the omission is repaired in the same change. It said `sendError`'s `extra` would not accept the field — true when written, false since `db8c288` (PR #12403) added `declaredCode` to the writer's `Pick`. Left alone it reads as "blocked upstream, nothing to do here" to the next author, which is the direction that costs a repair rather than an understanding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
1 parent 9a2f3df commit f206385

3 files changed

Lines changed: 551 additions & 9 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): the record-share family carries a demoted producer code on `declaredCode` (#12510)
6+
7+
`GET`/`POST /api/v1/data/:object/:id/shares` and `DELETE …/shares/:shareId` now
8+
put a producer's own error-code spelling on the wire's `error.declaredCode` when
9+
the closed ADR-0112 vocabulary did not admit it. Previously that spelling was
10+
resolved and then dropped at the re-dress: `respondSharingError`
11+
(`packages/rest/src/rest-server.ts`) asks `classifiedRefusalAnswer` — the flat
12+
`/data` door's own classification, which already carries the demoted string —
13+
and forwarded only `status`, `code` and the message into the nested ADR-0112 D5
14+
envelope.
15+
16+
Nothing invalid shipped, which is what made the loss silent and one-directional:
17+
the closed `code` still carried the member the HTTP status derives, so every
18+
body parsed, while an author's spelling vanished and a consumer told by ADR-0112
19+
to read `declaredCode` found nothing there. Measured before the repair, one
20+
producer through both doors: a thrown `{ code: 'CLOSE_PERIOD_LOCKED', status:
21+
409 }` answered `409 RESOURCE_CONFLICT` at both, with `declaredCode:
22+
'CLOSE_PERIOD_LOCKED'` at `/data` and nothing at the share door.
23+
24+
This ADOPTS the rule the sibling doors already apply rather than inventing one.
25+
The demote is `demotedDeclaredCode`'s answer — the single definition of
26+
"presence means demotion" — reached here through the classification's own
27+
`declaredCode`, which the flat door computes with exactly that function
28+
(`thrownCodeFields`, `packages/rest/src/error-response.ts`, #9232). The pair is
29+
carried, not recomputed: this door asks the classification once and re-dresses
30+
that one answer, as it already does for `status`, `code` and the message.
31+
32+
Additive and shape-preserving. A REGISTERED producer code still carries no
33+
`declaredCode` (repeating it would put two spellings of one fact on every
34+
refusal), a producer that declared no code still carries none, a non-string
35+
`code` is still context rather than a wire spelling, and `status`, `code` and
36+
`message` are byte-identical to before on every existing path. The five-prefix
37+
ADR-0111 idiom and the family's own `SHARES_LIST_FAILED` /
38+
`SHARE_GRANT_FAILED` / `SHARE_REVOKE_FAILED` 500 terminal are untouched.
39+
`ApiErrorSchema` has declared the field as optional since #9106, so the
40+
contract's accept set does not move.

packages/rest/src/rest-server.ts

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9581,7 +9581,14 @@ export class RestServer {
95819581
status: number,
95829582
code: ErrorCode,
95839583
message: string,
9584-
): void => sendEnvelopeError(res, status, code, message);
9584+
// [#12510] The shared writer's OWN `extra` type, referenced rather
9585+
// than restated: this wrapper decides POSITION (the nested D5
9586+
// envelope), never which channels exist. A local `{ declaredCode?:
9587+
// string }` would be a second, narrower declaration of a set
9588+
// `sendError` already owns — the shape that silently stops
9589+
// forwarding the next channel admitted there.
9590+
extra?: Parameters<typeof sendEnvelopeError>[4],
9591+
): void => sendEnvelopeError(res, status, code, message, extra);
95859592

95869593
const respond501 = (res: any) => respondError(
95879594
res, 501, 'NOT_IMPLEMENTED',
@@ -9651,17 +9658,57 @@ export class RestServer {
96519658
// is the one place the two dialects genuinely differ: the flat
96529659
// body may omit `code`, the nested one may not.
96539660
//
9654-
// ⚠️ Measured and NOT repaired here: an UNREGISTERED producer
9655-
// code is demoted by the shared resolver to a `declaredCode`
9656-
// sibling (ADR-0112 #9232), and `sendError`'s `extra` does not
9657-
// accept that field — so the author's own spelling is dropped
9658-
// on this family while `/data` carries it. Widening the shared
9659-
// envelope writer is a `@objectstack/types` change outside
9660-
// this card's surface; filed separately.
9661+
// [#12510] …and the producer's OWN spelling travels with it.
9662+
// An UNREGISTERED thrown code is demoted by the shared rule to
9663+
// a `declaredCode` sibling (ADR-0112 #9232) — the open,
9664+
// author-authored channel `ApiErrorSchema` has declared since
9665+
// #9106. This family used to drop it: the classification below
9666+
// was already holding the demoted string and only `code` and
9667+
// the message were re-dressed, so an app's spelling vanished
9668+
// here while the flat `/data` door carried it. Nothing invalid
9669+
// shipped — the closed `code` still carried the member the
9670+
// status derives — which is exactly what made the loss silent
9671+
// and one-directional: a consumer told by ADR-0112 to read
9672+
// `declaredCode` found nothing at this door.
9673+
//
9674+
// ⛔ The reason that used to stand here said `sendError`'s
9675+
// `extra` would not accept the field. That was true when it was
9676+
// written and false since #11719 / `db8c288` (PR #12403) added
9677+
// `declaredCode` to the writer's `Pick`. A stale sentence that
9678+
// discourages a repair costs more than one that misdescribes a
9679+
// mechanism, so it is recorded rather than merely deleted.
9680+
//
9681+
// ⛔ Read the CLASSIFICATION's field, never the resolver's raw
9682+
// `thrown.declaredCode`. Presence MEANS demotion
9683+
// (`ApiErrorSchema.declaredCode`'s documented invariant) and
9684+
// the raw field is set for a REGISTERED spelling too —
9685+
// measured: a producer throwing `{ code: 'RECORD_LOCKED',
9686+
// status: 409 }` resolves with `declaredCode: 'RECORD_LOCKED'`
9687+
// sitting beside an identical `code`, and forwarding that would
9688+
// put two spellings of one fact on every registered refusal.
9689+
// `refusal.body.declaredCode` is the answer AFTER
9690+
// `demotedDeclaredCode` (`error-response.ts`'s
9691+
// `thrownCodeFields`, the same one definition the dispatcher
9692+
// door reads), so the invariant arrives with the value.
9693+
//
9694+
// ⭐ Why re-dress rather than re-resolve: this door asks
9695+
// {@link classifiedRefusalAnswer} ONCE and re-dresses that one
9696+
// answer, exactly as it does for `status`, `code` and the
9697+
// message. Calling the resolver a second time here would be a
9698+
// second answer to a question already asked — the shape that
9699+
// let two `/api/v1/packages` doors drift apart (#12405). The
9700+
// pair is carried, not recomputed: `code` and `declaredCode`
9701+
// leave this door as the pair `thrownCodeFields` produced.
96619702
const code = typeof refusal.body.code === 'string'
96629703
? refusal.body.code as ErrorCode
96639704
: standardErrorCodeForHttpStatus(refusal.status);
9664-
respondError(res, refusal.status, code, String(refusal.body.error ?? ''));
9705+
const declaredCode = typeof refusal.body.declaredCode === 'string'
9706+
? refusal.body.declaredCode
9707+
: undefined;
9708+
respondError(
9709+
res, refusal.status, code, String(refusal.body.error ?? ''),
9710+
declaredCode !== undefined ? { declaredCode } : undefined,
9711+
);
96659712
return true;
96669713
}
96679714
const msg = String(error?.message ?? error ?? '');

0 commit comments

Comments
 (0)