Filed unassigned by the os-dev seat implementing objectui#8767 (session session_01Jmxdo7bmeqCQHLSfmLVX9w, PR #8960), on the rework round, at the dispatching seat's request. Not claiming, not grading — triage owns both.
The defect
ObjectGrid lowers schema.sort with its own private code rather than the shared sink convertSortToQueryParams. Its array arm is a bare template interpolation:
params.$orderby = schemaSort.map((s: any) => `${s.field} ${s.order}`).join(', ');
Every key it reads is interpolated unconditionally, so a member missing either key reaches the wire as the literal text undefined. The shared sink does not do this: it skips entries with no usable field and defaults a missing order to 'asc'.
This matters most for order, because that is the key an author is most likely to leave out — the diagnostic PR #8758 ships, which objectui#8767 now makes object-grid print, tells the author verbatim that "order is optional and means 'asc'". That prescription is true of the sink and false of this block: an author who follows the migration advice literally gets $orderby: 'name undefined'.
⚠️ SortConfig.order is in fact required on both declared faces — packages/types/src/objectql.ts (order: 'asc' | 'desc', no ?) and its zod mirror packages/types/src/zod/objectql.zod.ts (z.enum(['asc','desc']), no .optional()) — and the protocol's reusable SortItemSchema requires it too (measured on @objectstack/spec@17.4.0: z.array(SortItemSchema).safeParse([{ field: 'name' }]) refuses with invalid_value at 0.order). So this is not a case of the renderer having to honour an optional key. It is a lowering that turns a type error into silent garbage on the wire instead of dropping it or refusing it.
Measured — the reproducible probe
Rendered the real object-grid through SchemaRenderer against a vi.fn() data source and read $orderby off the first find call. Run on claude/issue-8767-object-grid-refuses-string-sort at 52d28eb4b. Recipe: render { type: 'object-grid', objectName: 'account', columns: [{ field: 'name' }], sort } inside a SchemaRendererProvider, await the first find, read params.$orderby.
authored sort (array arm) |
$orderby that goes out |
[{ field: 'name', order: 'desc' }] — CONTROL |
"name desc" |
[{ field: 'name' }] — order omitted |
"name undefined" |
[{ field: 'name', order: 'desc' }, { field: 'status' }] |
"name desc, status undefined" |
[] |
"" — an empty $orderby is sent rather than the key being omitted |
['name desc'] — the retired array-of-strings |
"undefined undefined" |
[{ order: 'desc' }] — field omitted |
"undefined desc" |
The control row is what makes the other five readings a measurement rather than a schema that mangles everything.
Not objectui#8767's, and why
objectui#8767's ruling (maintainer, 2026-09-10) is route C: refuse the retired string clause, keep the wire shape, touch nothing else. Widening that diff to normalise the array arm is exactly the direction the ruling refused. This defect is pre-existing and unchanged by that PR — the contract review measured every input class at base and at head and confirmed these five outputs move on neither side.
Relationship to objectui#8961
objectui#8961 parks the other half of the same divergence: the header-arrow reader parseSchemaSort is wider than the fetch path. Both cards exist because object-grid still owns two private lowerings of one key while every sibling block routes through the shared sink. The contract review's own suggestion was to append these array-arm readings to objectui#8961 rather than open a separate card; this is filed separately only because the dispatching seat asked for a dedicated card. ⇒ If triage prefers one card, fold this into objectui#8961 and close this as a duplicate — the readings above are the payload either way.
Whoever takes it should decide the array arm's fate together with those two, since a normalised arm and the shared sink's { field: direction } map are the same question asked twice.
Refs: objectui#8767 (the route-C ruling) · PR #8960 (its implementation, where this surfaced) · objectui#8961 (the header-reader half) · objectui#8221 (the retirement ruling) · PR #8758 (the shared sink's narrowing, whose prescription this arm contradicts)
Generated by Claude Code
Filed unassigned by the
os-devseat implementing objectui#8767 (sessionsession_01Jmxdo7bmeqCQHLSfmLVX9w, PR #8960), on the rework round, at the dispatching seat's request. Not claiming, not grading — triage owns both.The defect
ObjectGridlowersschema.sortwith its own private code rather than the shared sinkconvertSortToQueryParams. Its array arm is a bare template interpolation:Every key it reads is interpolated unconditionally, so a member missing either key reaches the wire as the literal text
undefined. The shared sink does not do this: it skips entries with no usablefieldand defaults a missingorderto'asc'.This matters most for
order, because that is the key an author is most likely to leave out — the diagnostic PR #8758 ships, which objectui#8767 now makesobject-gridprint, tells the author verbatim that "orderis optional and means'asc'". That prescription is true of the sink and false of this block: an author who follows the migration advice literally gets$orderby: 'name undefined'.SortConfig.orderis in fact required on both declared faces —packages/types/src/objectql.ts(order: 'asc' | 'desc', no?) and its zod mirrorpackages/types/src/zod/objectql.zod.ts(z.enum(['asc','desc']), no.optional()) — and the protocol's reusableSortItemSchemarequires it too (measured on@objectstack/spec@17.4.0:z.array(SortItemSchema).safeParse([{ field: 'name' }])refuses withinvalid_valueat0.order). So this is not a case of the renderer having to honour an optional key. It is a lowering that turns a type error into silent garbage on the wire instead of dropping it or refusing it.Measured — the reproducible probe
Rendered the real
object-gridthroughSchemaRendereragainst avi.fn()data source and read$orderbyoff the firstfindcall. Run onclaude/issue-8767-object-grid-refuses-string-sortat52d28eb4b. Recipe: render{ type: 'object-grid', objectName: 'account', columns: [{ field: 'name' }], sort }inside aSchemaRendererProvider, await the firstfind, readparams.$orderby.sort(array arm)$orderbythat goes out[{ field: 'name', order: 'desc' }]— CONTROL"name desc"[{ field: 'name' }]—orderomitted"name undefined"[{ field: 'name', order: 'desc' }, { field: 'status' }]"name desc, status undefined"[]""— an empty$orderbyis sent rather than the key being omitted['name desc']— the retired array-of-strings"undefined undefined"[{ order: 'desc' }]—fieldomitted"undefined desc"The control row is what makes the other five readings a measurement rather than a schema that mangles everything.
Not objectui#8767's, and why
objectui#8767's ruling (maintainer, 2026-09-10) is route C: refuse the retired string clause, keep the wire shape, touch nothing else. Widening that diff to normalise the array arm is exactly the direction the ruling refused. This defect is pre-existing and unchanged by that PR — the contract review measured every input class at base and at head and confirmed these five outputs move on neither side.
Relationship to objectui#8961
objectui#8961 parks the other half of the same divergence: the header-arrow reader
parseSchemaSortis wider than the fetch path. Both cards exist becauseobject-gridstill owns two private lowerings of one key while every sibling block routes through the shared sink. The contract review's own suggestion was to append these array-arm readings to objectui#8961 rather than open a separate card; this is filed separately only because the dispatching seat asked for a dedicated card. ⇒ If triage prefers one card, fold this into objectui#8961 and close this as a duplicate — the readings above are the payload either way.Whoever takes it should decide the array arm's fate together with those two, since a normalised arm and the shared sink's
{ field: direction }map are the same question asked twice.Refs: objectui#8767 (the route-C ruling) · PR #8960 (its implementation, where this surfaced) · objectui#8961 (the header-reader half) · objectui#8221 (the retirement ruling) · PR #8758 (the shared sink's narrowing, whose prescription this arm contradicts)
Generated by Claude Code