Skip to content

Commit a16ff50

Browse files
os-zhuangclaude
andauthored
Require a guaranteed warn channel on any sink type that declares an optional error (#9754) (#10555)
* feat(devx): a sink declaring an optional `error` must declare a guaranteed `warn` (#9754) An optional `error` with no declared alternative is a contract that permits silence. #9657 and #9748 repaired the call-site spellings; this is the type contract behind them. - `SweepLogger` (plugin-email) and `ProjectionLogger` (plugin-security) now declare `warn` non-optionally, so `{ info }` is no longer a legal sink and the durability reports cannot evaporate against one. `error` stays optional — hosts do inject reduced sinks (#9754 option C, falsified). - New producer-side checker `scripts/check-optional-error-sink-contract.mjs` (`pnpm check:optional-error-sink`): structural population, both narrowings reported as positive counts on every run, 13-case `--self-test`, and a shrink-only ledger holding the 15 sinks in `packages/**` that still permit silence. Not wired into CI — #9754 forbids more merge-blocking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt * test(plugin-security): drop the phantom compile-time pin its tsconfig cannot evaluate `pnpm check:type-check-coverage` caught it in this PR's own diff: plugin-security's tsconfig excludes `**/*.test.ts` (the package carries a TEST_DEBT ledger entry), so a `@ts-expect-error` in that file evaluates NEVER — a pin that reads like proof and holds nothing (AGENTS.md → "Build & Test"). The runtime half stays: a `{ info }` sink is shown hearing NOTHING — not the failure, not the count, not the reassuring "reconciled" line. The compile-time half lives in plugin-email's `outbox-sweep.test.ts`, whose package does compile its tests (observed red as `error TS2578: Unused '@ts-expect-error' directive` when the contract is reverted), and the type contract of both sinks is held by `pnpm check:optional-error-sink`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt * docs(plugins): unglue the two sink doc comments (`rejected). *`) A heredoc concatenation joined the option-C paragraph and the call-site backstop note onto one line in both interfaces. Comment text only; no code, type or test changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6abc4df commit a16ff50

8 files changed

Lines changed: 1010 additions & 2 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/plugin-email": minor
3+
"@objectstack/plugin-security": minor
4+
---
5+
6+
`SweepLogger` and `ProjectionLogger` now declare `warn` as a REQUIRED channel, so a sink handed to the boot outbox sweep or to permission-set reconciliation can no longer be one that prints nothing (#9754)
7+
8+
Both interfaces declared every member optional — `info?`, `warn?`, `error?` — which made `{ info }` a legal sink. Against such a sink both durability reports evaporated: each reaches for `error`, finds none, falls back to `warn`, and finds none of that either. For the sweep that is mail the platform accepted and never delivered, summarised to nobody; for reconciliation it is a permission set that will not survive a re-provision, with the `info` "reconciled" line skipped as well, so the sink heard neither the failure nor the reassurance.
9+
10+
#9657 and #9748 repaired the call-site spellings. This is the other half, and the half that cannot regress: an optional `error` with no guaranteed alternative is a contract that permits silence, so an author reading the interface can write a report that never prints and be right about the type. Requiring `warn` makes that unrepresentable at the point of authoring rather than catchable one gate-run later.
11+
12+
`error` deliberately stays optional on both types — hosts do inject reduced sinks, and requiring `error` would foreclose the `{ warn }`-only host the drivers were written for.
13+
14+
If you pass a logger of your own and it declares no `warn`, add one; the kernel `Logger`, `ctx.logger` and `console` all satisfy the tightened shape unchanged. Consumers reach these types through `@objectstack/plugin-security`'s exported `ProjectionDeps`; `SweepLogger` is internal to `@objectstack/plugin-email`.
15+
16+
The rule now has a checker of its own: `pnpm check:optional-error-sink` scans every sink type in `packages/**`, reports the population as a census on every run, and carries a shrink-only ledger of the 15 sinks that still permit silence.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"check:init-service-contract": "node scripts/check-init-service-contract.mjs --self-test && node scripts/check-init-service-contract.mjs",
8080
"check:kernel-hook-pairs": "node scripts/check-kernel-hook-pairs.mjs --self-test && node scripts/check-kernel-hook-pairs.mjs",
8181
"check:durability-log-level": "node scripts/check-durability-degradation-log-level.mjs --self-test && node scripts/check-durability-degradation-log-level.mjs",
82+
"check:optional-error-sink": "node scripts/check-optional-error-sink-contract.mjs --self-test && node scripts/check-optional-error-sink-contract.mjs",
8283
"check:startup-registry-verdict": "node scripts/check-startup-registry-verdict.mjs --self-test && node scripts/check-startup-registry-verdict.mjs",
8384
"check:console-sha": "node scripts/check-console-sha.mjs",
8485
"check:console-injection": "node scripts/check-console-injection.mjs --self-test && node scripts/check-console-injection.mjs",

packages/plugins/plugin-email/src/outbox-sweep.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,46 @@ describe('failures are loud, counted, and never stop the batch', () => {
331331
expect(lines(logger.warn).filter((l) => l.includes('could NOT be delivered'))).toHaveLength(0);
332332
});
333333

334+
it('[#9754] a sink with NO `warn` cannot be spelled at all — the TYPE forbids the silence', async () => {
335+
// THE HARM, reproduced before the fix is believed. Until #9754 every member
336+
// of `SweepLogger` was optional, so `{ info }` was a legal sink — and
337+
// against it BOTH repaired reports print nothing at all: each reaches for
338+
// `error`, finds none, falls back to `warn`, and finds none of that either.
339+
// Mail the platform accepted and never delivered, reported to nobody. The
340+
// cast below buys exactly what the old contract handed out for free.
341+
const engine = fakeEngine([{ id: 'row-bad-1', created_at: ago(min(30)) }]);
342+
const service = fakeService({
343+
deliver: () => { throw new Error('engine exploded'); },
344+
});
345+
const silent = { info: vi.fn() };
346+
347+
const res = await sweepStrandedOutbox({
348+
engine,
349+
service,
350+
logger: silent as unknown as NonNullable<Parameters<typeof sweepStrandedOutbox>[0]['logger']>,
351+
now: () => NOW,
352+
});
353+
354+
expect(res).toMatchObject({ scanned: 1, failed: 1 });
355+
// Not "logged at the wrong level" — not logged AT ALL, on either report.
356+
expect(lines(silent.info).filter((l) => l.includes('could NOT be delivered'))).toHaveLength(0);
357+
expect(lines(silent.info).filter((l) => l.includes('engine exploded'))).toHaveLength(0);
358+
359+
// THE CONTRACT. Without that cast the same sink no longer compiles: `warn`
360+
// is non-optional on `SweepLogger` (#9754), so a caller cannot hand over a
361+
// sink with nowhere to put a durability report. Restore `warn?` in
362+
// outbox-sweep.ts and this directive turns into an "Unused
363+
// '@ts-expect-error' directive" error — which is how this assertion proves
364+
// it is live rather than decorative.
365+
await sweepStrandedOutbox({
366+
engine,
367+
service,
368+
// @ts-expect-error — #9754: a SweepLogger MUST declare a `warn` channel
369+
logger: { info: vi.fn() },
370+
now: () => NOW,
371+
});
372+
});
373+
334374
it('propagates a failure of the query itself — the sweep did not happen', async () => {
335375
const engine = { find: vi.fn(async () => { throw new Error('no such table: sys_email'); }) };
336376
await expect(sweepStrandedOutbox({ engine, service: fakeService(), now: () => NOW }))

packages/plugins/plugin-email/src/outbox-sweep.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,28 @@ function humanMs(ms: number): string {
9292
*/
9393
interface SweepLogger {
9494
info?: (msg: string, meta?: any) => void;
95-
warn?: (msg: string, meta?: any) => void;
95+
/**
96+
* The GUARANTEED channel (#9754). `error` below stays optional — hosts do
97+
* inject reduced sinks — so `warn` is where a durability report lands when
98+
* `error` is absent, and a fallback that may itself be missing is not a
99+
* fallback: `{ info }` alone used to be a legal sink here, and against it the
100+
* boot sweep's "N stranded row(s) could NOT be delivered" summary printed
101+
* nothing at all. Non-optional is what makes that silence unrepresentable
102+
* instead of merely discouraged.
103+
*
104+
* ⛔ Do NOT "simplify" this by making `error` required instead — that
105+
* forecloses the reduced sinks hosts legitimately pass (#9754 option C,
106+
* measured and rejected).
107+
*
108+
* ⚠️ Call sites still spell the fallback `logger?.warn?.(…)`. That `?.` is not
109+
* doubt about this declaration — it is the backstop for hosts the TYPE cannot
110+
* reach (a plain-JS embedder, or a cast). Dropping it was measured: a sink
111+
* that lies about its shape then throws `logger?.warn is not a function`
112+
* INSIDE the per-row durability catch, aborting the very batch this function
113+
* promises never to stop. Silence for a lying host is the lesser failure; the
114+
* guarantee this member adds is at AUTHORING time, where it belongs.
115+
*/
116+
warn: (msg: string, meta?: any) => void;
96117
error?: (msg: string, meta?: any) => void;
97118
}
98119

packages/plugins/plugin-security/src/permission-set-projection.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
registerPermissionSetProjection,
3434
createPermissionSetWriteThrough,
3535
reconcilePermissionSetProjection,
36+
type ProjectionLogger,
3637
} from './permission-set-projection.js';
3738

3839
/** In-memory ql over sys_permission_set + sys_metadata. */
@@ -1106,6 +1107,48 @@ describe('reconcilePermissionSetProjection', () => {
11061107
expect(summary[0]!.meta?.failedNames).toEqual(['broken_set']);
11071108
});
11081109

1110+
it('[#9754] a sink with NO `warn` cannot be spelled at all — the TYPE forbids the silence', async () => {
1111+
// THE HARM, reproduced before the fix is believed. Until #9754 every member
1112+
// of `ProjectionLogger` was optional, so `{ info }` was a legal sink — and
1113+
// against it the reconcile pass reported NOTHING: the first-failure line
1114+
// and the summary both reach for `error`, fall back to `warn`, and find
1115+
// neither, while the `else` branch carrying the reassuring "reconciled"
1116+
// line is skipped because the pass did fail. A permission set that will not
1117+
// survive a re-provision, and a boot log that says nothing whatsoever.
1118+
const ql = makeQl();
1119+
const protocol = makeProtocol(ql);
1120+
ql.permRows.push({
1121+
id: 'ps_bad', name: 'broken_set', managed_by: 'admin', active: true,
1122+
label: 'Broken Set', object_permissions: JSON.stringify({ ticket: { allowRead: 'yes-please' } }),
1123+
});
1124+
const heard: string[] = [];
1125+
const silent = { info: (m: string) => heard.push(m) };
1126+
1127+
const out = await reconcilePermissionSetProjection(protocol, {
1128+
ql,
1129+
logger: silent as unknown as ProjectionLogger,
1130+
});
1131+
1132+
expect(out.backfillFailed).toBe(1);
1133+
expect(heard).toEqual([]); // neither the failure, nor the count, nor the "reconciled" line
1134+
1135+
// THE CONTRACT is what removes that cast's subject: `warn` is non-optional
1136+
// on `ProjectionLogger` (#9754), so no TS caller can build the sink above
1137+
// without saying `as unknown as` out loud.
1138+
//
1139+
// ⚠️ Deliberately NOT pinned here with `@ts-expect-error`. This package's
1140+
// tsconfig excludes `**/*.test.ts` (it carries a TEST_DEBT ledger entry in
1141+
// scripts/check-type-check-coverage.mjs), so no tsc program compiles this
1142+
// file and the directive would evaluate NEVER — a phantom check that reads
1143+
// like proof, which is the failure AGENTS.md → "Build & Test" names and
1144+
// `pnpm check:type-check-coverage` refuses. The compile-time half of this
1145+
// contract is pinned in plugin-email's `outbox-sweep.test.ts`, whose
1146+
// package DOES compile its tests (observed: reverting `warn` there turns
1147+
// that directive into `error TS2578: Unused '@ts-expect-error' directive`),
1148+
// and the type half of BOTH sinks is held by
1149+
// `pnpm check:optional-error-sink`.
1150+
});
1151+
11091152
it('heals a record that drifted from an EXISTING metadata definition (metadata wins)', async () => {
11101153
const ql = makeQl();
11111154
const declared = { member_default: envBody({ name: 'member_default', systemPermissions: ['declared.baseline'] }) };

packages/plugins/plugin-security/src/permission-set-projection.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,29 @@ export async function tryUpdate(ql: any, object: string, data: any): Promise<boo
9292

9393
export interface ProjectionLogger {
9494
info?: (m: string, meta?: Record<string, any>) => void;
95-
warn?: (m: string, meta?: Record<string, any>) => void;
95+
/**
96+
* The GUARANTEED channel (#9754). `error` below stays optional — hosts do
97+
* inject reduced sinks — so `warn` is where a durability report lands when
98+
* `error` is absent, and a fallback that may itself be missing is not a
99+
* fallback: `{ info }` alone used to be a legal sink here, and against it the
100+
* reconcile summary's "N FAILED backfill(s)" printed nothing at all, while
101+
* the `info` "reconciled" line was skipped too — the sink heard neither.
102+
* Non-optional is what makes that silence unrepresentable instead of merely
103+
* discouraged.
104+
*
105+
* ⛔ Do NOT "simplify" this by making `error` required instead — that
106+
* forecloses the reduced sinks hosts legitimately pass (#9754 option C,
107+
* measured and rejected).
108+
*
109+
* ⚠️ Call sites still spell the fallback `logger?.warn?.(…)`. That `?.` is not
110+
* doubt about this declaration — it is the backstop for hosts the TYPE cannot
111+
* reach (a plain-JS embedder, or a cast). Dropping it was measured: a sink
112+
* that lies about its shape then throws `logger?.warn is not a function`
113+
* INSIDE the per-row durability catch, aborting the very batch this function
114+
* promises never to stop. Silence for a lying host is the lesser failure; the
115+
* guarantee this member adds is at AUTHORING time, where it belongs.
116+
*/
117+
warn: (m: string, meta?: Record<string, any>) => void;
96118
/**
97119
* Durability-degradation channel (AGENTS.md "Degradation log levels", #4632):
98120
* a metadata write that was supposed to land and did not is an `error`, not a
@@ -809,6 +831,10 @@ export function createPermissionSetWriteThrough(
809831
// NOTHING against a sink that has only `warn` — the durability
810832
// degradation described above would then be reported by nobody at all
811833
// (#9657). Reach for `error`, fall back to `warn`, never to silence.
834+
// The fallback itself is now GUARANTEED BY THE TYPE: `warn` is
835+
// non-optional on `ProjectionLogger` (#9754), so no TS caller can
836+
// hand over a sink this line evaporates against. The `?.` below is
837+
// the backstop for untyped hosts only — see the interface.
812838
if (logger?.error) logger.error(message, e as Error, { name: row.name });
813839
else logger?.warn?.(message, { name: row.name, error: String((e as Error)?.message ?? e) });
814840
}
@@ -1040,6 +1066,10 @@ export async function reconcilePermissionSetProjection(
10401066
// NOTHING against a sink that has only `warn` — the durability
10411067
// degradation described above would then be reported by nobody at all
10421068
// (#9657). Reach for `error`, fall back to `warn`, never to silence.
1069+
// The fallback itself is now GUARANTEED BY THE TYPE: `warn` is
1070+
// non-optional on `ProjectionLogger` (#9754), so no TS caller can
1071+
// hand over a sink this line evaporates against. The `?.` below is
1072+
// the backstop for untyped hosts only — see the interface.
10431073
if (logger?.error) logger.error(message, e as Error, { name: row.name });
10441074
else logger?.warn?.(message, { name: row.name, error: String((e as Error)?.message ?? e) });
10451075
}
@@ -1076,6 +1106,9 @@ export async function reconcilePermissionSetProjection(
10761106
// the `else` below is skipped too, so such a sink heard neither the count
10771107
// nor the reassuring "reconciled" line, while the first-failure report
10781108
// (repaired by #9657) still arrived. Fall back to `warn`, not silence.
1109+
// The fallback itself is now GUARANTEED BY THE TYPE: `warn` is
1110+
// non-optional on `ProjectionLogger` (#9754). The `?.` is the backstop for
1111+
// untyped hosts only — see the interface.
10791112
if (logger?.error) logger.error(summary, undefined, summaryMeta);
10801113
else logger?.warn?.(summary, summaryMeta);
10811114
} else {

0 commit comments

Comments
 (0)