Measured on origin/main@b03ba3a (2026-08-31) while closing objectui#4535, whose declared file surface was packages/types/src/objectql.ts + packages/plugin-grid/src/ObjectGrid.tsx. The defect below sits outside that surface, so it was reported rather than fixed in that PR.
Note on spelling: generic type arguments below are written with a space after the opening bracket (z.input< typeof X >). GitHub's body sanitizer silently eats short tag-shaped fragments, and it ate one of these on the first publish of this card.
What landed, and what did not
objectui#4535 / objectstack#8010 reconciled exportOptions with the spec's five-key object form. The reconciliation landed for ObjectGrid: ListViewExportOptions in packages/types/src/objectql.ts declares exactly formats, maxRecords, includeHeaders, fileNamePrefix, streaming; 'pdf' is out of ListViewExportFormat; ObjectGrid.tsx reads streaming with no cast; two guards pin both directions.
The ListView leg did not. ListView is the component ObjectView actually renders for a saved view, and it carries a second, unreconciled declaration of the same key.
1. The zod mirror still declares the pre-#8010 shape
packages/types/src/zod/objectql.zod.ts:511-518:
exportOptions: z.union([
z.array(z.enum(['csv', 'xlsx', 'json', 'pdf'])),
z.object({
formats: z.array(z.enum(['csv', 'xlsx', 'json', 'pdf'])).optional(),
maxRecords: z.number().optional(),
includeHeaders: z.boolean().optional(),
fileNamePrefix: z.string().optional(),
}),
]).optional().describe('Export options'),
Three separate drifts against @objectstack/spec@17.2.0 (the installed pin), each measured against the spec's own ListViewSchema.shape.exportOptions:
'pdf' is accepted here, in both spellings. The spec's enum is ["csv","xlsx","json"] and a declared 'pdf' is a parse-time refusal carrying an os migrate meta --from 16 prescription. objectui validates a value the platform then refuses at publish — the author sees green locally and a refusal upstream.
streaming is declared nowhere. It is the fifth spec key and the renderer honours it (below). This is precisely the undeclared-but-read shape objectstack#8010 was filed for, still open on this surface.
- The object branch is not strict. The spec's is
strictObject (catchall never), so a sixth key is refused upstream and silently stripped here.
This is not cosmetic: packages/types/src/objectql.ts:2121 is export type ListViewSchema = ListViewInferred & ListViewRuntimeProps, and ListViewInferred = z.input< typeof ListViewSchema > (the zod one) reads from this declaration. So ListViewSchema['exportOptions'] — the TYPE the ListView renderer is written against — is the legacy union with 'pdf' and without streaming, while its sibling ObjectGridSchema['exportOptions'] is the clean five-key ListViewExportOptions. Two authoring surfaces for one spec key, disagreeing.
Same failure class as objectui#4605 (the zod BaseSchema mirror still declares visible/disabled as boolean ... after the TS declarations widened), and it lives under the objectui#2231 umbrella.
2. Both as any streaming reads survive in ListView
packages/plugin-list/src/ListView.tsx:
:1324 — && (resolvedExportOptions as any)?.streaming !== false;
:2705 — && (exportConfig as any)?.streaming !== false;
These are objectui#4535 item 3's exact defect, in the other renderer. The casts are not gratuitous — they are load-bearing because of item 1: the key is genuinely absent from the type these reads go through. Fixing the zod mirror is what lets them be deleted, so the two halves are one change.
:1304 carries the same 'pdf' in its cast:
return { formats: schema.exportOptions as Array<'csv' | 'xlsx' | 'json' | 'pdf'> };
3. Keep the array tolerance — measured, not assumed
:1299-1308's resolvedExportOptions fold (bare array to { formats }) must STAY. objectui#4535 item 4 asked whether raw un-parsed metadata can still reach the renderer, and it can:
normalizeListViewSchema (packages/core/src/utils/normalize-list-view.ts) does not touch exportOptions — zero occurrences in the file.
- Nothing on the render path runs
.parse()/.safeParse() on a view schema; the only such calls in the repo are in tests. That is exactly why ListViewInferred is z.input and not z.infer, as its own doc comment records.
packages/app-shell/src/views/ObjectView.tsx:2116 forwards the stored value verbatim: exportOptions: viewDef.allowExport === false ? undefined : (viewDef.exportOptions ?? listSchema.exportOptions).
So the spec's parse-time array lift never runs before the renderer, and a stored bare-array declaration still arrives as an array. The tolerance is load-bearing, not legacy. Note ObjectGrid has none — a bare array reaching it degrades to the ['csv','json'] default, silently ignoring the author's declared formats. Worth deciding deliberately rather than inheriting.
Suggested shape
- Rebuild
zod/objectql.zod.ts's exportOptions as the spec's two-branch union: z.array(SpecFormatEnum).transform((formats) => ({ formats })) union a z.strictObject of the five keys, 'pdf' gone from both. Ideally derived from ListViewSchema.shape.exportOptions in @objectstack/spec/ui rather than restated — it is a live export, and packages/types already depends on the spec.
- Delete both
as any casts in ListView.tsx and the 'pdf' in the :1304 cast; keep the array fold.
- Extend the pin:
packages/types/src/__tests__/export-options-spec-parity.test.ts (landed with objectui#4535) explicitly scopes itself to the TS declaration and states the zod mirror is unmeasured. Widen it once this is fixed.
Also worth folding in
ListViewExportOptions is entry 1 in the @object-ui/types list of CLAIM_DEBT in scripts/check-spec-symbol-derivation.mjs:665 — the shrink-only ledger of unbacked spec-alignment claims. Its claim is now backed by a real parity test, and the mirror's reason is measured (ListViewExportOptionsSchema is internal to the spec bundle, not a public export — only ListViewSchema is). That makes it a clean burn-down via the ledger's own route 2: move it to CLAIM_ALLOW with that reason. Not done in objectui#4535's PR because scripts/** was outside its file surface and editing a gate script pulls in that script's own test suite.
Re-check commands
git grep -n "exportOptions" -- packages/types/src/zod/objectql.zod.ts packages/plugin-list/src/ListView.tsx
git grep -n "as any)?.streaming" -- packages/plugin-list/src/ListView.tsx
git grep -n "exportOptions" -- packages/core/src/utils/normalize-list-view.ts # expect zero hits
Spec side, against the installed pin rather than a working tree:
import { ListViewSchema } from '@objectstack/spec/ui';
const eo = ListViewSchema.shape.exportOptions;
eo.safeParse(['csv','xlsx']); // { formats: ['csv','xlsx'] } — the lift
eo.safeParse(['csv','pdf']).success; // false, with the migration prescription
eo.safeParse({ formats:['csv'], compression:'gzip' }).success; // false — strict
Measured on
origin/main@b03ba3a(2026-08-31) while closing objectui#4535, whose declared file surface waspackages/types/src/objectql.ts+packages/plugin-grid/src/ObjectGrid.tsx. The defect below sits outside that surface, so it was reported rather than fixed in that PR.Note on spelling: generic type arguments below are written with a space after the opening bracket (
z.input< typeof X >). GitHub's body sanitizer silently eats short tag-shaped fragments, and it ate one of these on the first publish of this card.What landed, and what did not
objectui#4535 / objectstack#8010 reconciled
exportOptionswith the spec's five-key object form. The reconciliation landed for ObjectGrid:ListViewExportOptionsinpackages/types/src/objectql.tsdeclares exactlyformats,maxRecords,includeHeaders,fileNamePrefix,streaming;'pdf'is out ofListViewExportFormat;ObjectGrid.tsxreadsstreamingwith no cast; two guards pin both directions.The ListView leg did not.
ListViewis the componentObjectViewactually renders for a saved view, and it carries a second, unreconciled declaration of the same key.1. The zod mirror still declares the pre-#8010 shape
packages/types/src/zod/objectql.zod.ts:511-518:Three separate drifts against
@objectstack/spec@17.2.0(the installed pin), each measured against the spec's ownListViewSchema.shape.exportOptions:'pdf'is accepted here, in both spellings. The spec's enum is["csv","xlsx","json"]and a declared'pdf'is a parse-time refusal carrying anos migrate meta --from 16prescription. objectui validates a value the platform then refuses at publish — the author sees green locally and a refusal upstream.streamingis declared nowhere. It is the fifth spec key and the renderer honours it (below). This is precisely the undeclared-but-read shape objectstack#8010 was filed for, still open on this surface.strictObject(catchallnever), so a sixth key is refused upstream and silently stripped here.This is not cosmetic:
packages/types/src/objectql.ts:2121isexport type ListViewSchema = ListViewInferred & ListViewRuntimeProps, andListViewInferred = z.input< typeof ListViewSchema >(the zod one) reads from this declaration. SoListViewSchema['exportOptions']— the TYPE the ListView renderer is written against — is the legacy union with'pdf'and withoutstreaming, while its siblingObjectGridSchema['exportOptions']is the clean five-keyListViewExportOptions. Two authoring surfaces for one spec key, disagreeing.Same failure class as objectui#4605 (
the zod BaseSchema mirror still declares visible/disabled as boolean ... after the TS declarations widened), and it lives under the objectui#2231 umbrella.2. Both
as anystreamingreads survive in ListViewpackages/plugin-list/src/ListView.tsx::1324—&& (resolvedExportOptions as any)?.streaming !== false;:2705—&& (exportConfig as any)?.streaming !== false;These are objectui#4535 item 3's exact defect, in the other renderer. The casts are not gratuitous — they are load-bearing because of item 1: the key is genuinely absent from the type these reads go through. Fixing the zod mirror is what lets them be deleted, so the two halves are one change.
:1304carries the same'pdf'in its cast:3. Keep the array tolerance — measured, not assumed
:1299-1308'sresolvedExportOptionsfold (bare array to{ formats }) must STAY. objectui#4535 item 4 asked whether raw un-parsed metadata can still reach the renderer, and it can:normalizeListViewSchema(packages/core/src/utils/normalize-list-view.ts) does not touchexportOptions— zero occurrences in the file..parse()/.safeParse()on a view schema; the only such calls in the repo are in tests. That is exactly whyListViewInferredisz.inputand notz.infer, as its own doc comment records.packages/app-shell/src/views/ObjectView.tsx:2116forwards the stored value verbatim:exportOptions: viewDef.allowExport === false ? undefined : (viewDef.exportOptions ?? listSchema.exportOptions).So the spec's parse-time array lift never runs before the renderer, and a stored bare-array declaration still arrives as an array. The tolerance is load-bearing, not legacy. Note
ObjectGridhas none — a bare array reaching it degrades to the['csv','json']default, silently ignoring the author's declared formats. Worth deciding deliberately rather than inheriting.Suggested shape
zod/objectql.zod.ts'sexportOptionsas the spec's two-branch union:z.array(SpecFormatEnum).transform((formats) => ({ formats }))union az.strictObjectof the five keys,'pdf'gone from both. Ideally derived fromListViewSchema.shape.exportOptionsin@objectstack/spec/uirather than restated — it is a live export, andpackages/typesalready depends on the spec.as anycasts inListView.tsxand the'pdf'in the:1304cast; keep the array fold.packages/types/src/__tests__/export-options-spec-parity.test.ts(landed with objectui#4535) explicitly scopes itself to the TS declaration and states the zod mirror is unmeasured. Widen it once this is fixed.Also worth folding in
ListViewExportOptionsis entry 1 in the@object-ui/typeslist ofCLAIM_DEBTinscripts/check-spec-symbol-derivation.mjs:665— the shrink-only ledger of unbacked spec-alignment claims. Its claim is now backed by a real parity test, and the mirror's reason is measured (ListViewExportOptionsSchemais internal to the spec bundle, not a public export — onlyListViewSchemais). That makes it a clean burn-down via the ledger's own route 2: move it toCLAIM_ALLOWwith that reason. Not done in objectui#4535's PR becausescripts/**was outside its file surface and editing a gate script pulls in that script's own test suite.Re-check commands
Spec side, against the installed pin rather than a working tree: