Skip to content

Commit f9a5c59

Browse files
os-helpclaude
andauthored
fix(service-messaging): close DeliveryPayload.severity to its declared vocabulary (#7174) (#7382)
`'info' | 'warning' | 'critical' | string` collapses to exactly `string` in TypeScript's type system — the trailing `| string` absorbs the three literal members, so the closed vocabulary enforced nothing. Drop it, aligning DeliveryPayload with its already-closed siblings (messaging-service.ts EmitInput.severity, channel.ts Notification.severity, the inbox-message select field). Adds a compile-time pin (outbox-delivery-payload-severity.test.ts) proving severity: 'urgent' is now a type error, reverse-verified against the pre-fix declaration (@ts-expect-error goes unused / TS2578 pre-fix, clean post-fix). No runtime behaviour change: every real construction site already writes input.severity ?? 'info' where input.severity is itself the already-closed EmitInput.severity. Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1998f7a commit f9a5c59

3 files changed

Lines changed: 72 additions & 1 deletion

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/service-messaging": patch
3+
---
4+
5+
fix(service-messaging): close `DeliveryPayload.severity` to `'info' | 'warning' | 'critical'` (#7174)
6+
7+
`DeliveryPayload.severity` was declared `'info' | 'warning' | 'critical' | string`.
8+
TypeScript absorbs a literal union member into the wider primitive it is unioned
9+
with, so this was exactly `string` — the three names read as a closed vocabulary
10+
but enforced nothing. `severity: 'urgent'` (or `''`) type-checked with no error,
11+
even though the value flows into `inbox-channel.ts`'s `n.severity ?? 'info'` and
12+
the `sys_inbox_message.severity` select column, whose options are the three
13+
names, and even though this package's three sibling declarations of the same
14+
concept — `MessagingService['emit']`'s `EmitInput.severity`
15+
(`messaging-service.ts`), `MessagingChannel`'s `Notification['severity']`
16+
(`channel.ts`), and the `inbox-message` object's `severity` select field — are
17+
already closed to exactly this set.
18+
19+
Dropping the trailing `| string` makes the type mean what it says. No runtime
20+
behaviour changes — every real producer already writes `input.severity ?? 'info'`
21+
where `input.severity` is itself the already-closed `EmitInput.severity`, so no
22+
in-repo construction site changes shape. This is the type-level twin of #7086
23+
(`NotifyConfigSchema.severity`, closed in PR #7192): a construction site that
24+
would previously narrow silently through the collapsed union now gets a
25+
compiler refusal instead, named as a `@ts-expect-error` pin.
26+
27+
This is a narrowing of a publicly exported type
28+
(`packages/services/service-messaging/src/outbox.ts`), so a consumer assigning
29+
an out-of-vocabulary literal directly to `DeliveryPayload.severity` would newly
30+
fail to compile — hence `patch`, following the #7140 precedent for a type-side
31+
enforcement tightening with no runtime behaviour change.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #7174 — compile-time pin for `DeliveryPayload.severity`.
5+
*
6+
* The field used to be declared `'info' | 'warning' | 'critical' | string`,
7+
* which TypeScript collapses to exactly `string` (a literal union member is
8+
* absorbed by the wider primitive it is unioned with). The three names read
9+
* as a closed vocabulary but enforced nothing — `severity: 'urgent'`
10+
* type-checked with no error. This file is the guard-rail: every line below
11+
* is a type-level assertion evaluated by `tsc --noEmit`, run as part of this
12+
* package's ordinary `typecheck` script (this package's tsconfig does not
13+
* exclude `*.test.ts`, so no separate `.pin.ts` is needed — see AGENTS.md's
14+
* `PINS_CHECKED` invariant).
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import type { DeliveryPayload } from './outbox.js';
19+
20+
describe('DeliveryPayload.severity (#7174)', () => {
21+
it('accepts the closed info | warning | critical vocabulary', () => {
22+
const payloads: DeliveryPayload[] = [
23+
{ severity: 'info' },
24+
{ severity: 'warning' },
25+
{ severity: 'critical' },
26+
{}, // severity is optional
27+
];
28+
expect(payloads.map((p) => p.severity)).toEqual(['info', 'warning', 'critical', undefined]);
29+
});
30+
31+
it('rejects an out-of-vocabulary literal at compile time', () => {
32+
// @ts-expect-error 'urgent' is not a member of the closed severity vocabulary
33+
const bad: DeliveryPayload = { severity: 'urgent' };
34+
// Runtime side is unreachable in practice (a real construction site
35+
// would fail to compile); assert only that the pin above is real —
36+
// if the `| string` collapse ever comes back, this line stops being
37+
// an error and `tsc --noEmit` goes red on the missing `@ts-expect-error`.
38+
expect(bad.severity).toBe('urgent');
39+
});
40+
});

packages/services/service-messaging/src/outbox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export type DeliveryStatus =
2222
export interface DeliveryPayload {
2323
title?: string;
2424
body?: string;
25-
severity?: 'info' | 'warning' | 'critical' | string;
25+
severity?: 'info' | 'warning' | 'critical';
2626
actionUrl?: string;
2727
[k: string]: unknown;
2828
}

0 commit comments

Comments
 (0)