Found while implementing objectui#3935 (replacing writeWarningToast's two-way conditional with an exhaustive reason table). Filed unassigned — recording only, not claiming.
Fact
packages/data-objectstack/src/index.ts — notifyDroppedFields filters the response's droppedFields entries on SHAPE only:
const valid = dropped.filter(
(e): e is DroppedFieldsEvent =>
!!e && typeof e === 'object' && Array.isArray((e as DroppedFieldsEvent).fields) && (e as DroppedFieldsEvent).fields.length > 0,
);
reason is never read, never compared against DroppedFieldsEventSchema.shape.reason, and the predicate is a hand-written type guard that asserts the entry into the spec type regardless. notifyBatchDroppedFields, a few lines down, does the same thing (const e = entry as DroppedFieldsEvent & { index?: number }).
So a value outside 'readonly' | 'readonly_when' | 'primary_key' — a server running ahead of this bundle's @objectstack/spec pin, which is the normal skew direction for a deployed client — reaches consumers typed as if it were inside the union. Declared is not enforced at the one boundary that reads the wire.
Why this is observation-class today
objectui has exactly one consumer of the dropped reason: a grep for .reason across packages/ and apps/ finds only writeWarningToast's two byReason lines. After objectui#3935 that consumer handles the off-union value explicitly — an exhaustive Record< DroppedFieldsEvent['reason'], … > for the declared arms, plus a documented cause-free line for anything else, pinned by a test. Nothing a user hits today.
What makes it worth recording is the direction of the gap: the interior is now typed to trust a union the boundary never checked. The next consumer to branch on reason gets an exhaustive-looking table and a type that lies to it, and no gate in the repo would say so.
Options, deliberately without a recommendation
- Parse at the boundary with
DroppedFieldsEventSchema.safeParse and keep only entries that pass. But a dropped entry means the user is told nothing about those fields, which is precisely the silence objectui#3484 exists to remove — so this is a behaviour ruling, not a mechanical hardening.
- Keep the entry and widen the type at the boundary so the interior sees the unparsed reality (
reason: string), pushing narrowing to consumers. That is the opposite of objectui#3160's ruling, which deliberately re-exported THE spec type rather than a hand-widened string; it needs that ruling revisited, not reversed quietly.
- Leave it as is and require each consumer to document the skew case, as objectui#3935 did.
Related: objectui#3935, objectui#3160, objectui#3484, objectstack#6437.
Found while implementing objectui#3935 (replacing
writeWarningToast's two-way conditional with an exhaustive reason table). Filed unassigned — recording only, not claiming.Fact
packages/data-objectstack/src/index.ts—notifyDroppedFieldsfilters the response'sdroppedFieldsentries on SHAPE only:reasonis never read, never compared againstDroppedFieldsEventSchema.shape.reason, and the predicate is a hand-written type guard that asserts the entry into the spec type regardless.notifyBatchDroppedFields, a few lines down, does the same thing (const e = entry as DroppedFieldsEvent & { index?: number }).So a value outside
'readonly' | 'readonly_when' | 'primary_key'— a server running ahead of this bundle's@objectstack/specpin, which is the normal skew direction for a deployed client — reaches consumers typed as if it were inside the union. Declared is not enforced at the one boundary that reads the wire.Why this is observation-class today
objectui has exactly one consumer of the dropped
reason: a grep for.reasonacrosspackages/andapps/finds onlywriteWarningToast's twobyReasonlines. After objectui#3935 that consumer handles the off-union value explicitly — an exhaustiveRecord< DroppedFieldsEvent['reason'], … >for the declared arms, plus a documented cause-free line for anything else, pinned by a test. Nothing a user hits today.What makes it worth recording is the direction of the gap: the interior is now typed to trust a union the boundary never checked. The next consumer to branch on
reasongets an exhaustive-looking table and a type that lies to it, and no gate in the repo would say so.Options, deliberately without a recommendation
DroppedFieldsEventSchema.safeParseand keep only entries that pass. But a dropped entry means the user is told nothing about those fields, which is precisely the silence objectui#3484 exists to remove — so this is a behaviour ruling, not a mechanical hardening.reason: string), pushing narrowing to consumers. That is the opposite of objectui#3160's ruling, which deliberately re-exported THE spec type rather than a hand-widenedstring; it needs that ruling revisited, not reversed quietly.Related: objectui#3935, objectui#3160, objectui#3484, objectstack#6437.