Humanising an Error Message Cost the Dead-Letter Its Trace
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Reuse a human-readable message written for an admin screen as the reason text on a dead-letter, and the machine-readable identifier disappears — the log can no longer name the school that caused it.
Conclusion
An error message localised into human-readable Japanese for operators can, when reused for a different purpose — a log read by machines — take a machine-readable identifier down with it. The coverage validation validateMonitorSchoolCoverage in the signage delivery platform returns wording designed to be shown as-is on the admin form. Reusing that same wording as a dead-letter last_error made “which school” — obvious on the screen — unreadable from the log. Review caught it, and the fix was to add machine-readable code and schools fields to the validation result, separating the UI wording from the identifiers.
Background
When a loop placement broadcasting to several schools targets monitors directly, the portal validates that “every target school has at least one monitor assigned” through a pure function, validateMonitorSchoolCoverage. It was originally written for save-time validation on the admin form, so the failure reason was Japanese intended to go straight onto the screen.
// pre-fix equivalent (slot-model.ts)
export type LoopTargetingResult =
| { ok: true }
| { ok: false; error: string };
Following a review finding about multi-school fan-out, the separate coverage check that the delivery payload builder (delivery-payload.ts) had written for itself was removed and everything consolidated onto validateMonitorSchoolCoverage. The aim was to structurally prevent the invariant “no monitor from outside the target schools is mixed in” from being missed. That consolidation was the right call and is not the subject here.
Cause
After consolidation, when validateMonitorSchoolCoverage returned ok: false, the delivery side packed coverage.error — the Japanese written for the admin form — straight into reason and returned it upstream.
// immediately after consolidation (delivery-payload.ts, pre-fix)
if (!coverage.ok) {
return {
ok: false,
reason: `placement ${placementId} monitor targeting is inconsistent with its ${targetSchoolIds.length} target schools: ${coverage.error}`,
};
}
That reason is recorded as sync_outbox.last_error when the delivery queue fails, and it is the first string an operator reads when investigating a dead letter. But coverage.error was Japanese along the lines of “a monitor belonging to a school outside the target set has been selected” — containing no school ID at all. On the admin form the placement is already open, target schools and monitors are visible in a list, and there is no need to name the school in the wording. A dead-letter log has none of that surrounding context; the operator works from the last_error string alone. An error with no school ID cannot identify which school in a multi-school loop caused it, leaving no option but to reopen the placement and cross-check by hand.
The fix
Add machine-readable fields to validateMonitorSchoolCoverage’s return value alongside the UI-facing error: a code (parity / stray_school / uncovered_school) and schools, the offending school IDs.
// post-fix (slot-model.ts)
export type MonitorSchoolCoverageResult =
| { ok: true }
| {
ok: false;
error: string;
/** parity = index correspondence broken / stray_school = school outside the target set / uncovered_school = school with no monitor. */
code: "parity" | "stray_school" | "uncovered_school";
/** portal schools.id of the offending schools (empty for parity, which cannot name one). */
schools: string[];
};
On the delivery side, code and schools are embedded at the end of reason. The screen-facing error stays exactly as it was; only the identifiers are added separately.
// post-fix (delivery-payload.ts)
if (!coverage.ok) {
// Make the reason text enough for an operator to reach the offending school from
// sync_outbox.last_error alone (coverage.error is admin-form Japanese with no IDs).
return {
ok: false,
reason:
`placement ${placementId} monitor targeting is inconsistent with its ` +
`${targetSchoolIds.length} target schools [${coverage.code}` +
`${coverage.schools.length ? `: ${coverage.schools.join(", ")}` : ""}]: ${coverage.error}`,
};
}
The admin form caller still reads only coverage.error and its display is unchanged. Only the reason that reaches the dead letter now carries a machine-readable fragment such as [stray_school: <school-id>].
Preventing a repeat
The unit tests calling this function directly were extended to assert code and schools, pinning the correct code and school IDs for each of the three failure modes (parity, stray_school, uncovered_school).
Generalised: when a validator’s return value can be used both as text displayed on a screen and as a string read mechanically from a log, one error: string must not serve both. Screen-facing wording is usually written assuming the reader already has the context, and piping that same string to a consumer without that context — logs, notifications, monitoring — drops exactly the identifiers the wording omitted. Providing machine-readable fields alongside the human wording from the outset was the only way to make that reuse safe.
よくある質問
Q1Why was the school ID missing from the error message?
The error was designed as Japanese shown directly on the admin form. That screen already has the placement open, so which school it means is obvious and the wording never needed an ID. A dead-letter log has no such context, so the ID was simply gone.
Q2Why was this found in review rather than in production?
It came up while consolidating coverage validation into a single function on the slot-model side; review noted that this function's return value also feeds the dead-letter last_error. A follow-up commit landed five minutes later, so there is no sign the code stayed in that state for long.
Q3Does this fix apply to other validation functions?
Yes. Whenever a validator's return value may be used both as text on a screen and as a string read mechanically from a log, give it machine-readable fields — a code and the relevant identifiers — alongside the human wording, so each caller can pick what it needs.
確認した環境
- kimiteras-portal: Next.js 16.2.7 / @supabase/supabase-js ^2.106.2
- Found in review and fixed the same day, 2026-07-24
この記事の根拠
- TypeScriptファイル 306〜323行目コミット 3cb1f8a
- TypeScriptファイル 339〜397行目コミット 3cb1f8a
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。