From 09af7e1dfbf70659287db5183a54e42da15428fd Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:31:10 +0000 Subject: [PATCH 1/4] fix(data-objectstack): parse a write-strip's `reason` at the boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `notifyDroppedFields` filtered a create/update response's `droppedFields` on SHAPE alone — a hand-written `e is DroppedFieldsEvent` guard checking only `Array.isArray(fields)` — so a `reason` outside the spec enum reached every subscriber typed as though it were inside the union. A deployed client normally runs behind the server it talks to, so a reason from the future is the expected skew direction. `notifyBatchDroppedFields` did the same through its cast. Both paths now read `reason` against `DroppedFieldsEventSchema.shape.reason`, keep every entry, and route an unrecognized one to a named skew arm carrying the wire value verbatim. The spec type stays the canonical arm — no widening to `string`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../4934-dropped-fields-reason-boundary.md | 37 ++++ .../src/droppedFieldsReason.boundary.test.ts | 195 ++++++++++++++++++ packages/data-objectstack/src/index.ts | 142 ++++++++++++- 3 files changed, 364 insertions(+), 10 deletions(-) create mode 100644 .changeset/4934-dropped-fields-reason-boundary.md create mode 100644 packages/data-objectstack/src/droppedFieldsReason.boundary.test.ts diff --git a/.changeset/4934-dropped-fields-reason-boundary.md b/.changeset/4934-dropped-fields-reason-boundary.md new file mode 100644 index 000000000..a196bad1f --- /dev/null +++ b/.changeset/4934-dropped-fields-reason-boundary.md @@ -0,0 +1,37 @@ +--- +'@object-ui/data-objectstack': minor +--- + +Parse a write-strip's `reason` against the spec enum at the boundary +(objectui#4934). + +`notifyDroppedFields` filtered a create/update response's `droppedFields` on +SHAPE alone — a hand-written `e is DroppedFieldsEvent` guard that checked +`Array.isArray(fields)` and nothing else — so a `reason` outside +`'readonly' | 'readonly_when' | 'primary_key'` reached every subscriber typed as +though it were inside the union. A deployed client normally runs BEHIND the +server it talks to, so a reason from the future is the expected skew direction, +not a corrupt payload; the interior was typed to trust a union no one had +checked, and nothing in the repo could say so. `notifyBatchDroppedFields` did +the same through its `entry as DroppedFieldsEvent & { index?: number }` cast. + +Both paths now read `reason` against `DroppedFieldsEventSchema.shape.reason` — +the enum the installed pin declares, derived rather than restated, so a pin bump +that adds an arm widens the accept set on its own: + +- **Every entry is kept.** Dropping the unparsable ones would tell the user + nothing about fields the server really did strip, which is exactly the silence + objectui#3484 removed. +- An unrecognized `reason` arrives on a named skew arm, + `UnrecognizedDropReasonEvent`, carrying `UNRECOGNIZED_DROP_REASON` plus the + wire value **verbatim** in `unrecognizedReason` — never coerced onto a known + arm, because claiming `readonly` for a reason we cannot name is a false + statement about the user's data. +- `WriteWarningEvent['droppedFields']` is therefore the two-arm + `DroppedFieldsNotice`. The spec type stays the canonical arm and is not + widened to `string` (objectui#3160): the skew arm is not assignable to + `DroppedFieldsEvent`, so a consumer branching on `reason` now hears about + server skew from `tsc` instead of from a per-consumer discipline. + +Runtime wording is unchanged: the one reader, the app shell's write-warning +toast, already answered an unrecognized reason with its cause-free line. diff --git a/packages/data-objectstack/src/droppedFieldsReason.boundary.test.ts b/packages/data-objectstack/src/droppedFieldsReason.boundary.test.ts new file mode 100644 index 000000000..7fc32c42c --- /dev/null +++ b/packages/data-objectstack/src/droppedFieldsReason.boundary.test.ts @@ -0,0 +1,195 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The write-warning boundary must PARSE `reason` against the spec enum, not + * assert it into the union on shape alone (objectui#4934). + * + * `notifyDroppedFields` used to filter the wire entries on shape only — an + * `Array.isArray(fields)` predicate hand-written as `e is DroppedFieldsEvent` — + * so a `reason` the bundle's `@objectstack/spec` pin has never heard of reached + * every subscriber typed as if it were inside + * `'readonly' | 'readonly_when' | 'primary_key'`. A server running AHEAD of a + * deployed client's pin is the normal skew direction, so that type was a lie the + * repo had no gate for. + * + * The population is empty today (nothing emits an off-union reason), so a green + * suite proves nothing by itself. What these tests pin is the DISCRIMINATION: + * an off-union reason lands on the named skew arm carrying the wire value + * verbatim, and an in-union one still arrives on the canonical spec arm + * untouched. The skew case was measured red against the pre-fix boundary. + */ +import { describe, it, expect, vi } from 'vitest'; +import { DroppedFieldsEventSchema } from '@objectstack/spec/data'; +import { ObjectStackAdapter, UNRECOGNIZED_DROP_REASON } from './index'; +import type { + DroppedFieldsEvent, + DroppedFieldsNotice, + WriteWarningEvent, +} from './index'; + +function makeDS(stub: Record) { + const ds: any = new ObjectStackAdapter({ + baseUrl: 'http://test.local', + fetch: vi.fn(async () => + new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + }); + ds.connected = true; + ds.connectionState = 'connected'; + ds.client = { data: stub }; + return ds; +} + +/** Drive one create whose response carries `droppedFields`, return the events. */ +async function emitOnCreate(droppedFields: unknown[]): Promise { + const create = vi.fn().mockResolvedValue({ record: { id: 'r1' }, droppedFields }); + const ds = makeDS({ create }); + const events: WriteWarningEvent[] = []; + ds.onWriteWarning((e: WriteWarningEvent) => events.push(e)); + await ds.create('andon', { type: 'x', title: 'T' }); + return events; +} + +describe('dropped-fields `reason` is parsed at the boundary (#4934)', () => { + it('routes a reason ahead of the spec pin to the skew arm, verbatim', async () => { + const events = await emitOnCreate([ + { object: 'andon', fields: ['type'], reason: 'some_future_reason' }, + ]); + + expect(events).toHaveLength(1); + const [notice] = events[0].droppedFields; + // The lie this card exists to delete: the value must NOT arrive typed and + // spelled as though it were a member of the spec union. + expect(notice.reason).not.toBe('some_future_reason'); + expect(notice).toEqual({ + object: 'andon', + fields: ['type'], + reason: UNRECOGNIZED_DROP_REASON, + unrecognizedReason: 'some_future_reason', + }); + }); + + it('KEEPS the entry — an unparsable reason never silences the warning (#3484)', async () => { + const events = await emitOnCreate([ + { object: 'andon', fields: ['type'], reason: 'some_future_reason' }, + { object: 'andon', fields: ['source_method'], reason: 'readonly' }, + ]); + + // Both entries survive, in wire order: dropping the skew one would recreate + // exactly the silence objectui#3484 removed. + expect(events[0].droppedFields).toHaveLength(2); + expect(events[0].droppedFields[0].fields).toEqual(['type']); + expect(events[0].droppedFields[1].fields).toEqual(['source_method']); + }); + + it('CONTROL — every reason the installed spec declares still arrives untouched', async () => { + const declared = DroppedFieldsEventSchema.shape.reason.options; + expect(declared).toContain('primary_key'); + + for (const reason of declared) { + const events = await emitOnCreate([{ object: 'andon', fields: ['type'], reason }]); + expect(events).toHaveLength(1); + // Canonical arm: byte-identical to the wire entry, no skew bookkeeping. + expect(events[0].droppedFields[0]).toEqual({ + object: 'andon', + fields: ['type'], + reason, + }); + expect(events[0].droppedFields[0]).not.toHaveProperty('unrecognizedReason'); + } + }); + + /** + * The TYPE-level half of the fix, and the half the runtime assertions above + * cannot see: the skew arm must not be assignable to the spec type. That is + * what turns "a server ahead of our pin" from a per-consumer discipline into + * a `tsc` error at every consumer that branches on `reason` — the gate the + * card recorded as missing. Test files are inside this package's `type-check` + * program (its tsconfig includes every file under `src`), so these two lines + * are enforced, not decoration. + */ + it('the skew arm is NOT assignable to the spec type (compile-time pin)', () => { + const skew: DroppedFieldsNotice = { + object: 'andon', + fields: ['type'], + reason: UNRECOGNIZED_DROP_REASON, + unrecognizedReason: 'some_future_reason', + }; + // @ts-expect-error — if this ever compiles, the boundary type is lying again. + const asSpecEvent: DroppedFieldsEvent = skew; + + const canonical: DroppedFieldsNotice = { object: 'andon', fields: ['type'], reason: 'readonly' }; + // The canonical arm still IS the spec type (objectui#3160) — no widening. + const stillTheSpecType: DroppedFieldsEvent = canonical as DroppedFieldsEvent; + + expect(asSpecEvent.fields).toEqual(['type']); + expect(stillTheSpecType.reason).toBe('readonly'); + }); + + it('the skew sentinel is not — and must never become — a spec arm', () => { + const declared: readonly string[] = DroppedFieldsEventSchema.shape.reason.options; + expect(declared).not.toContain(UNRECOGNIZED_DROP_REASON); + }); + + it('a non-string or missing reason is skew too, kept verbatim', async () => { + const events = await emitOnCreate([ + { object: 'andon', fields: ['type'], reason: 42 }, + { object: 'andon', fields: ['source_method'] }, + ]); + + expect(events[0].droppedFields[0]).toEqual({ + object: 'andon', + fields: ['type'], + reason: UNRECOGNIZED_DROP_REASON, + unrecognizedReason: 42, + }); + expect(events[0].droppedFields[1]).toEqual({ + object: 'andon', + fields: ['source_method'], + reason: UNRECOGNIZED_DROP_REASON, + unrecognizedReason: undefined, + }); + }); + + it('the cross-object batch path parses the same way (#3794)', async () => { + const batchTransaction = vi.fn().mockResolvedValue({ + results: [{ id: 'inv1' }], + droppedFields: [ + { object: 'invoice', fields: ['tax_rate'], reason: 'some_future_reason', index: 0 }, + ], + }); + const ds = makeDS({ batchTransaction }); + ds.atomicBatchCapability = true; + const events: WriteWarningEvent[] = []; + ds.onWriteWarning((e: WriteWarningEvent) => events.push(e)); + + await ds.batchTransaction([ + { object: 'invoice', action: 'update', id: 'inv1', data: { tax_rate: 9 } }, + ]); + + expect(events).toEqual([ + { + operation: 'update', + resource: 'invoice', + id: 'inv1', + droppedFields: [ + { + object: 'invoice', + fields: ['tax_rate'], + reason: UNRECOGNIZED_DROP_REASON, + unrecognizedReason: 'some_future_reason', + }, + ], + }, + ]); + }); +}); diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index f4fb56f86..a9168e261 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -8,6 +8,11 @@ import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; +// #4934 — a VALUE import, not a type one: the write-warning boundary parses the +// wire's `reason` against the enum the spec itself declares, so the accept set +// is read off the pin instead of hand-copied here (a hand copy is the drift +// this seam already paid for once — see `DroppedFieldsEvent`'s comment below). +import { DroppedFieldsEventSchema } from '@objectstack/spec/data'; import type { ApiError } from '@objectstack/spec/api'; // #4237 — the metadata save door's advisory reader, shared with `MetadataClient` // rather than forked. ONE reader, two call sites: the other client class calls it @@ -1355,17 +1360,118 @@ export type BatchProgressListener = (event: BatchProgressEvent) => void; */ export type { DroppedFieldsEvent }; +/** + * The `reason` values THIS bundle's `@objectstack/spec` pin declares, read off + * `DroppedFieldsEventSchema` rather than restated (objectui#4934). + * + * Derived, so a pin bump that adds an arm widens the accept set here on its own + * — the alternative is a hand list that silently classifies a brand-new spec + * reason as skew, which is the same drift in the other direction. + */ +const RECOGNIZED_DROP_REASONS: ReadonlySet = new Set( + DroppedFieldsEventSchema.shape.reason.options, +); + +/** + * The `reason` of a write-strip this bundle's spec pin cannot name + * (objectui#4934). + * + * Deliberately NOT a spec spelling: it is namespaced so it can never collide + * with an arm `@objectstack/spec` adds later (a collision would merge real + * reasons into the skew bucket — the failure this whole card is about, one level + * up). `droppedFieldsReason.boundary.test.ts` pins that the installed spec does + * not declare it. + */ +export const UNRECOGNIZED_DROP_REASON = 'objectui:unrecognized-drop-reason'; + +/** + * The skew arm: one server-reported write-strip whose `reason` is outside the + * enum this bundle's `@objectstack/spec` pin declares (objectui#4934). + * + * A deployed client normally runs BEHIND the server it talks to, so a reason + * from the future is the expected skew direction, not a corrupt payload. The + * boundary used to assert such an entry into {@link DroppedFieldsEvent} on shape + * alone — `reason` was never read — so the interior was typed to trust a union + * nothing had checked, and the next consumer to write an exhaustive-looking + * table over `DroppedFieldsEvent['reason']` would have been handed a value that + * type says is impossible. + * + * Three properties of this shape are load-bearing: + * + * - The entry is **kept**. Dropping it would tell the user nothing about fields + * the server really did strip — precisely the silence objectui#3484 removed. + * - The wire value is **preserved verbatim** in {@link unrecognizedReason}, + * never coerced or normalised onto a known arm: claiming `readonly` for a + * reason we cannot name is a false statement about the user's data, and it is + * also unfalsifiable once the original value is gone. + * - `reason` carries {@link UNRECOGNIZED_DROP_REASON}, which is not assignable + * to `DroppedFieldsEvent['reason']`. That is what makes the skew case visible + * to `tsc` at every consumer instead of resting on N per-consumer + * disciplines — and it keeps the spec type as the canonical arm rather than + * widening the whole surface to `string` (objectui#3160). + */ +export interface UnrecognizedDropReasonEvent { + object?: string; + fields: string[]; + reason: typeof UNRECOGNIZED_DROP_REASON; + /** Whatever the server sent, untouched — including a non-string or nothing at all. */ + unrecognizedReason: unknown; +} + +/** + * One entry of a write-warning: either the spec type (canonical arm) or the + * named skew arm above. Narrow with `entry.reason === UNRECOGNIZED_DROP_REASON`. + */ +export type DroppedFieldsNotice = DroppedFieldsEvent | UnrecognizedDropReasonEvent; + +/** + * A `droppedFields` entry as it comes OFF THE WIRE: everything a structural + * check can honestly claim about it, and no more. `reason` is `unknown` because + * nothing has parsed it yet — writing `DroppedFieldsEvent` here is the exact + * assertion objectui#4934 exists to delete. + */ +type WireDroppedFieldsEntry = Omit & { reason?: unknown }; + +/** Whether the wire's `reason` is an arm the installed spec pin declares. */ +function isRecognizedDropReason(reason: unknown): reason is DroppedFieldsEvent['reason'] { + return RECOGNIZED_DROP_REASONS.has(reason); +} + +/** + * Classify ONE wire entry by parsing its `reason` against the spec enum + * (objectui#4934). + * + * A recognized entry is passed through by reference — unchanged, extra + * server-sent keys and all — so this is a classification, not a rewrite; only + * the skew case builds a new object. The cast on that path is the one kind this + * seam may still make: `reason` has just been PARSED, so the claim is proven + * rather than assumed. + */ +function asDroppedFieldsNotice(entry: WireDroppedFieldsEntry): DroppedFieldsNotice { + if (isRecognizedDropReason(entry.reason)) return entry as DroppedFieldsEvent; + return { + ...entry, + reason: UNRECOGNIZED_DROP_REASON, + unrecognizedReason: entry.reason, + }; +} + /** * Emitted after a create/update whose response carried `droppedFields` * (framework #3431/#3455). The write SUCCEEDED — this is a warning that some * supplied fields never landed, so the UI can tell the user rather than let it * pass silently. Subscribe via {@link ObjectStackAdapter.onWriteWarning}. + * + * `droppedFields` is the two-arm {@link DroppedFieldsNotice} and not + * `DroppedFieldsEvent[]`: the wire is parsed here, and an entry whose `reason` + * this bundle's spec pin cannot name arrives on the explicit skew arm rather + * than being asserted into the union (objectui#4934). */ export interface WriteWarningEvent { operation: 'create' | 'update'; resource: string; id?: string | number; - droppedFields: DroppedFieldsEvent[]; + droppedFields: DroppedFieldsNotice[]; } /** Event listener type for write-warning (dropped-fields) events. */ @@ -1425,14 +1531,14 @@ function sameWireValue(a: unknown, b: unknown): boolean { * back empty, which suppresses the warning entirely. */ function withoutNoOpDrops( - droppedFields: DroppedFieldsEvent[], + droppedFields: DroppedFieldsNotice[], sent: Record | undefined | null, stored: Record | undefined | null, -): DroppedFieldsEvent[] { +): DroppedFieldsNotice[] { if (!sent || !stored || typeof sent !== 'object' || typeof stored !== 'object') { return droppedFields; } - const out: DroppedFieldsEvent[] = []; + const out: DroppedFieldsNotice[] = []; for (const e of droppedFields) { const kept = e.fields.filter((f) => { if (!Object.prototype.hasOwnProperty.call(sent, f)) return true; @@ -2355,6 +2461,11 @@ export class ObjectStackAdapter implements DataSource { * and, when present, notify write-warning subscribers. Tolerant of a client * whose response type predates `droppedFields`: the field is read structurally * and validated, so an older client (or a backend that never drops) is a no-op. + * + * SHAPE decides whether an entry is an event at all (it must carry a non-empty + * `fields`); `reason` is then PARSED against the spec enum and an unrecognized + * one routed to the skew arm — never asserted into the union, and never + * dropped (objectui#4934). */ private notifyDroppedFields( operation: 'create' | 'update', @@ -2365,10 +2476,15 @@ export class ObjectStackAdapter implements DataSource { ): void { const dropped = (result as { droppedFields?: unknown } | null | undefined)?.droppedFields; if (!Array.isArray(dropped) || dropped.length === 0) return; - const valid = dropped.filter( - (e): e is DroppedFieldsEvent => - !!e && typeof e === 'object' && Array.isArray((e as DroppedFieldsEvent).fields) && (e as DroppedFieldsEvent).fields.length > 0, - ); + const valid = dropped + .filter( + (e): e is WireDroppedFieldsEntry => + !!e && + typeof e === 'object' && + Array.isArray((e as WireDroppedFieldsEntry).fields) && + (e as WireDroppedFieldsEntry).fields.length > 0, + ) + .map(asDroppedFieldsNotice); // A strip that changed nothing is not news — see withoutNoOpDrops (#3484). const stored = (result as { record?: Record } | null | undefined)?.record; const droppedFields = withoutNoOpDrops(valid, sent, stored); @@ -2397,7 +2513,9 @@ export class ObjectStackAdapter implements DataSource { const results = (payload as { results?: unknown[] } | null | undefined)?.results; for (const entry of dropped) { if (!entry || typeof entry !== 'object') continue; - const e = entry as DroppedFieldsEvent & { index?: number }; + // The cast claims only the structure this loop checks; `reason` stays + // unparsed until `asDroppedFieldsNotice` below (objectui#4934). + const e = entry as WireDroppedFieldsEntry & { index?: number }; if (!Array.isArray(e.fields) || e.fields.length === 0) continue; const op = typeof e.index === 'number' ? operations[e.index] : undefined; // Same no-op suppression as the single-record path (#3484). The echoed @@ -2407,8 +2525,12 @@ export class ObjectStackAdapter implements DataSource { typeof e.index === 'number' && Array.isArray(results) ? (results[e.index] as Record | undefined) : undefined; + // `reason` is parsed against the spec enum here too — the batch path used + // to re-assert the wire value into the union via the cast above + // (objectui#4934). `index` is deliberately not carried onto the notice: + // it addresses an operation in THIS response, not the strip. const [live] = withoutNoOpDrops( - [{ object: e.object, fields: e.fields, reason: e.reason }], + [asDroppedFieldsNotice({ object: e.object, fields: e.fields, reason: e.reason })], op?.data as Record | undefined, stored, ); From c01e5a2ae8d5b31d364ae54230c0d23bc21eb145 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 10:54:19 +0000 Subject: [PATCH 2/4] fix(app-shell): widen the toast's `reason` annotations to the notice union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boundary now parses a write-strip's `reason` against the spec enum (objectui#4934), so `WriteWarningEvent['droppedFields']` is the two-arm `DroppedFieldsNotice[]` and a skew `reason` is deliberately NOT assignable to `DroppedFieldsEvent['reason']`. Two annotations in `writeWarningToast.ts` were pinned to the spec union and stopped compiling. Both were already narrower than the file's own documented contract. `lineFor` looks the reason up through a widened view of `STRIPPED_LINE`, and its docstring says outright that the runtime value may sit outside that union — so the `undefined` this branch handles is reachable, not dead. Only the parameter and the `Map` key had been left on the union; this corrects them rather than admitting a new case. `STRIPPED_LINE` keeps its `Record` declaration, so objectui#3935's guarantee — a new SPEC arm fails `type-check` unworded — survives untouched. Widen the lookups, not the table. Zero runtime change, measured rather than asserted: the emitted JavaScript is byte-identical across the diff (tsc transpile, sha256 6a5c5a95…, 6686 bytes each) and the toast's 13 wording tests pass unchanged. The changeset now states the blast radius: a consumer branching exhaustively on `reason` must widen its annotation, and that compile error is the intended signal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../4934-dropped-fields-reason-boundary.md | 23 +++++++++++++++++++ .../src/providers/writeWarningToast.ts | 5 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.changeset/4934-dropped-fields-reason-boundary.md b/.changeset/4934-dropped-fields-reason-boundary.md index a196bad1f..6e5426090 100644 --- a/.changeset/4934-dropped-fields-reason-boundary.md +++ b/.changeset/4934-dropped-fields-reason-boundary.md @@ -35,3 +35,26 @@ that adds an arm widens the accept set on its own: Runtime wording is unchanged: the one reader, the app shell's write-warning toast, already answered an unrecognized reason with its cause-free line. + +**Blast radius — the compile error IS the intended signal, not a regression.** A +consumer that branches exhaustively on `reason` — a parameter, a `Map` key or a +`Record` annotated `DroppedFieldsEvent['reason']` — stops compiling against this +release, with a `TS2345` at each such site. That error is the notification, and +the only one: the skew arm is deliberately NOT assignable to the spec union, so +`tsc` reports server skew at the one place the wire is read rather than leaving +it to a per-consumer discipline. Do not cast it away. Widen the annotation to +`DroppedFieldsNotice['reason']`, and where the two arms have to be told apart, +narrow with `entry.reason === UNRECOGNIZED_DROP_REASON` and read the wire value +verbatim from `unrecognizedReason`. + +Widen the LOOKUPS, not the table. A `Record` that must stay exhaustive over the +SPEC arms keeps its `DroppedFieldsEvent['reason']` key: widening that one would +trade away the guarantee that a newly pinned spec reason fails `type-check` +unworded (objectui#3935). + +In this repo the entire blast radius is the app shell's write-warning toast — +two type annotations, no runtime change. Its emitted JavaScript is byte-identical +and its wording tests pass unchanged, because the file was already written for +this value: its own docstring says the runtime `reason` may sit outside the spec +union and that the cause-free fallback is reachable, not dead. Only the parameter +and the `Map` key had been left narrower than that documented contract. diff --git a/packages/app-shell/src/providers/writeWarningToast.ts b/packages/app-shell/src/providers/writeWarningToast.ts index 83ece4b9a..c4560fd22 100644 --- a/packages/app-shell/src/providers/writeWarningToast.ts +++ b/packages/app-shell/src/providers/writeWarningToast.ts @@ -17,6 +17,7 @@ import type { DroppedFieldsEvent, + DroppedFieldsNotice, ObjectStackAdapter, WriteWarningEvent, } from '@object-ui/data-objectstack'; @@ -154,7 +155,7 @@ const strippedLineUnknownReason: StrippedLine = (t: TranslateFn, fields: string) * union (see {@link strippedLineUnknownReason}); the `undefined` this branch * handles is therefore reachable, not dead. */ -function lineFor(reason: DroppedFieldsEvent['reason']): StrippedLine { +function lineFor(reason: DroppedFieldsNotice['reason']): StrippedLine { const known: Partial> = STRIPPED_LINE; return known[reason] ?? strippedLineUnknownReason; } @@ -187,7 +188,7 @@ export async function emitWriteWarning( fieldLabel: FieldLabelFn, sink: WriteWarningSink, ): Promise { - const byReason = new Map(); + const byReason = new Map(); for (const d of ev.droppedFields) { const seen = byReason.get(d.reason) ?? []; for (const f of d.fields) if (!seen.includes(f)) seen.push(f); From 3ec17debb46ec5d1dfdcba54dfadb1e0951df1d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 11:18:54 +0000 Subject: [PATCH 3/4] docs(app-shell): correct the two docstrings this PR's boundary change falsified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `strippedLineUnknownReason` still described the PRE-fix boundary — the adapter "asserts the entry into `DroppedFieldsEvent` without ever checking the value against the spec enum". It checks it now: `notifyDroppedFields` parses `reason` and routes an unrecognized value to the named skew arm. The conclusion the sentence supports is unchanged — `UNRECOGNIZED_DROP_REASON` is still not a key of `STRIPPED_LINE`, so this fallback is still reachable — only the mechanism was stale. `lineFor`'s docstring opened "The PARAMETER carries the spec union", which it no longer does, and hung `STRIPPED_LINE`'s exhaustiveness on that parameter. That causal claim was already loose before this PR: exhaustiveness comes from the table's own `Record` declaration, never from this signature. Both are now stated truthfully. Comments only, and bounded: `STRIPPED_LINE`'s declaration does not move, and the sentence about the widened lookup being reachable rather than dead is untouched to the byte. The comment-stripped transpile is identical before and after (sha256 f9ff9b85b9cf39e28caf81a4bd99708bfc36b903b190af35fe56bca731e91bf8, both sides, 0 diagnostics); every changed line of the comment-preserving emit is a comment line. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../app-shell/src/providers/writeWarningToast.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/app-shell/src/providers/writeWarningToast.ts b/packages/app-shell/src/providers/writeWarningToast.ts index c4560fd22..ac40d66b1 100644 --- a/packages/app-shell/src/providers/writeWarningToast.ts +++ b/packages/app-shell/src/providers/writeWarningToast.ts @@ -129,10 +129,10 @@ const STRIPPED_LINE: Record = { * What to say for a reason THIS bundle's spec pin has never heard of. * * A real runtime state rather than a limb the types already ruled out: the - * adapter's `notifyDroppedFields` reads `reason` structurally off the wire and - * asserts the entry into `DroppedFieldsEvent` without ever checking the value - * against the spec enum, so a server running ahead of the bundle's pin delivers - * one the table above cannot possibly have an arm for. Both of the other + * adapter's `notifyDroppedFields` PARSES `reason` against the spec enum and + * routes a value the enum does not name onto its explicit skew arm, whose + * `UNRECOGNIZED_DROP_REASON` is by construction not a key of the table above — + * so a server running ahead of the bundle's pin still arrives here. Both of the other * dispositions are worse: indexing blindly would throw inside an `async` * function the adapter invokes as `void emitWriteWarning(...)`, so the rejection * goes unhandled and the user loses the whole toast INCLUDING the reasons that @@ -149,8 +149,10 @@ const strippedLineUnknownReason: StrippedLine = (t: TranslateFn, fields: string) /** * Resolve one reason to its sentence. * - * The PARAMETER carries the spec union — that is what makes {@link STRIPPED_LINE} - * exhaustive-checked at its declaration above. The LOOKUP is done through a + * The PARAMETER carries the two-arm notice union, not the spec union. + * {@link STRIPPED_LINE}'s exhaustiveness has never come from this signature — + * it comes from that table's own declaration above being keyed by + * `DroppedFieldsEvent['reason']`. The LOOKUP is done through a * widened view of the same table, because the runtime value may sit outside that * union (see {@link strippedLineUnknownReason}); the `undefined` this branch * handles is therefore reachable, not dead. From 2b8b48f82d71e7262a2a2efed8e6a196523c0714 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 11:43:40 +0000 Subject: [PATCH 4/4] docs(changeset): qualify the byte-identical claim to the executable emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset said of the app shell "Its emitted JavaScript is byte-identical". That was true when written, at `c01e5a2` — both emit modes were identical there. The docstring commit `3ec17deb` falsified its comment-kept half: `tsconfig.base.json:22` sets `removeComments: false` and `@object-ui/app-shell` builds with a bare `tsc`, so docstring bytes do reach `dist/index.js` and `dist/index.d.ts`, and the real build's emit differs (6726 B -> 6854 B, sha256 `6a5c5a95...` -> `393f0180...`), every differing line a comment line. The EXECUTABLE emit is unchanged and measured so: comments-stripped, sha256 `f9ff9b85...` / 2181 B on both sides. One word, so the sentence says that. This one is worth a commit where the identical slip in the PR body and in the ruling was answered with a comment: a changeset is a release-notes input. It is compiled into published notes and read by consumers who cannot see this thread, so an unqualified claim here becomes the record rather than costing a reader a moment. Bump level, structure and every other line are untouched, and no `@object-ui/app-shell` entry is added: the executable emit is unchanged, and a comment-only difference in dist is not a published behaviour change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .changeset/4934-dropped-fields-reason-boundary.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/4934-dropped-fields-reason-boundary.md b/.changeset/4934-dropped-fields-reason-boundary.md index 6e5426090..3cbc10d5c 100644 --- a/.changeset/4934-dropped-fields-reason-boundary.md +++ b/.changeset/4934-dropped-fields-reason-boundary.md @@ -53,7 +53,7 @@ trade away the guarantee that a newly pinned spec reason fails `type-check` unworded (objectui#3935). In this repo the entire blast radius is the app shell's write-warning toast — -two type annotations, no runtime change. Its emitted JavaScript is byte-identical +two type annotations, no runtime change. Its executable JavaScript is byte-identical and its wording tests pass unchanged, because the file was already written for this value: its own docstring says the runtime `reason` may sit outside the spec union and that the cause-free fallback is reachable, not dead. Only the parameter