Skip to content

feat(errors): carry a log-safe message variant on the error - #568

Merged
dwirz merged 1 commit into
mainfrom
feat/log-safe-error-message
Sep 3, 2026
Merged

feat(errors): carry a log-safe message variant on the error#568
dwirz merged 1 commit into
mainfrom
feat/log-safe-error-message

Conversation

@smartive-nicolai

Copy link
Copy Markdown
Contributor

Why

Follow-up to #557, which fixed the wrong half of the problem.

#557 suppressed the display inside getTechnicalDisplay itself, so it disappeared from the message the caller receives as well. That is the half that should not change: whoever triggered a failed delete or restore has to know which record is meant, and a bare id does not tell them — least of all a non-admin role, which does receive these messages. A log is the only place with no business keeping the name.

What

sensitiveDisplay keeps exactly its current meaning — the model author's declaration that a display holds personal data — and is consumed differently. The error now carries a second, log-safe variant of its own message:

message:    User "Jane Doe (jane@example.com)" (0b7e…) is not deleted.
logMessage: User 0b7e… is not deleted.
  • logMessage sits on the GraphQLError base, so ForbiddenError, NotFoundError, UserInputError and PermissionError all inherit it.
  • It is a class field, not an extension. Extensions are serialized to the client, so carrying it there would change the response payload — the exact thing this PR exists to avoid.
  • getTechnicalDisplay goes back to always quoting the display (29.3.3 behaviour); the suppression now lives in the new getLogSafeTechnicalDisplay.

How the throw sites stay honest

The prose is written once and rendered twice, through either display renderer:

throw forbidden((display) => `${display(model, entity)} is not deleted.`);

technicalMessage(build) returns { message, logMessage }; forbidden(build) wraps that in a ForbiddenError. Because both variants come from one template, they cannot drift apart — and a message that names several entities has each of them redacted, which matters for the restrict and cascade messages, since those name two.

All eight display-bearing throw sites in resolvers/mutations.ts are converted. resolvers/resolver.ts:73 is left alone deliberately: it builds its display from { id } only, so there is no display to suppress.

For consumers

Anyone who adopted 29.4.0 and set the flag gets their client-facing message back, and should switch to logging logMessage:

catch (error) {
  console.error(error instanceof GraphQLError && error.logMessage ? error.logMessage : error);
  throw error;
}

Verification

  • npm run lint clean, tsc --noEmit clean, unit suite 163/163.
  • tests/unit/technical-display.spec.ts rewritten for the new split: the caller-facing renderer keeps the display including for a sensitive model, the log-safe renderer drops it, a non-sensitive model is identical in both variants, and a two-entity message has both redacted.
  • No api snapshot changes: no test model sets sensitiveDisplay, and this restores the message shape those snapshots already record.
  • Docs updated (docs/docs/2-models.md) — the flag's section now describes what it does and does not affect.

Note

The delete dry-run payload is untouched. Those displays are what an admin confirms a deletion against, and the dry-run is not a log.

Follow-up to #557, which took the wrong half of the problem. It suppressed the
display in the message itself, so the caller lost it too — but whoever triggered
a failed delete or restore has to know which record is meant, and a bare id does
not tell them. Only a log has no business keeping the name.

So `sensitiveDisplay` keeps its meaning as the model author's declaration that a
display is personal data, and is consumed differently: the error now carries a
second, log-safe variant of its own message.

    message:    User "Jane Doe (jane@example.com)" (0b7e…) is not deleted.
    logMessage: User 0b7e… is not deleted.

`logMessage` lives on the `GraphQLError` base, so every subclass inherits it. It
is deliberately a class field rather than an extension: extensions are
serialized to the client, so carrying it there would change the response.

The throw sites state their prose once and it is rendered twice, through either
the full or the log-safe display renderer (`technicalMessage`, and `forbidden`
for the common case). The two variants therefore cannot drift apart, and a
message naming several entities has each of them redacted — the restrict and
cascade messages name two.

`getTechnicalDisplay` goes back to always quoting the display, which is what
29.3.3 did; the suppression now lives in `getLogSafeTechnicalDisplay`. Consumers
on 29.4.0 that set the flag get their client-facing message back and should
switch to logging `logMessage`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nicola-smartive
nicola-smartive marked this pull request as ready for review September 3, 2026 13:41
@dwirz
dwirz merged commit 31cd257 into main Sep 3, 2026
23 checks passed
@dwirz
dwirz deleted the feat/log-safe-error-message branch September 3, 2026 13:42
nicola-smartive pushed a commit that referenced this pull request Sep 3, 2026
)

The release for #568 failed, and every release would have. #564 bumped
conventional-changelog-conventionalcommits to 10.4.0, and 10.x cannot be
rendered by the writer our release actually runs.

Releases go through cycjimmy/semantic-release-action with semantic_version: 25.
semantic-release@25 depends on @semantic-release/release-notes-generator@^14.1.0,
14.1.1 is the latest, and it pins conventional-changelog-writer@^8 — resolving
8.4.0. So no semantic-release upgrade escapes this today.

Preset 10 plants a deliberate tripwire for exactly this mismatch: its writer
options carry a `mainTemplate` whose text is the error message
(@conventional-changelog/template, createLegacyWriterGuard). A modern writer has
no `mainTemplate` and ignores it; a handlebars writer (<=8) compiles it, hits
helperMissing and throws. Hence "Missing helper: ... requires
conventional-changelog-writer@9 or newer".

Neither major declares a peerDependency on the writer, so nothing warned, and
nothing in the test suite touches the release path — the bump went green and
broke publishing instead.

Verified against the real component rather than by reading version ranges:
@semantic-release/release-notes-generator@14 (writer 8.4.0) throws the exact
error with preset 10.4.0, and generates correct notes with 9.3.1.

renovate is told to hold the preset below 10 until release-notes-generator ships
a writer@9, with the reasoning in the rule so the next person does not simply
un-pin it.

Co-authored-by: zwei-wealth-dev <38549277-zwei-wealth-dev@users.noreply.gitlab.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 29.6.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant