Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/10296-detail-view-field-options-authoring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@object-ui/types': minor
---

feat(types)!: `DetailViewFieldSchema.options` is the spec's authoring `SelectOptionSchema` (objectui#10296)

The zod mirror of a detail-view field's `options` carried an inline option shape
that admitted a number or boolean `value` — which the TypeScript declaration
(`SelectOptionMetadata[]`) refuses — and silently stripped every other key. It
now reads `SelectOptionSchema` from `@objectstack/spec/data` by reference, per
ruling F1 on objectui#7759: a structural mirror points at the spec's AUTHORING
schema, never at the declaration's runtime read model.

BREAKING for authored detail-view metadata that the old shape let through:

- `value` must be the spec's `SystemIdentifierSchema`: a string matching
`^[a-z][a-z0-9_.]*$`, at least two characters long. So `open`, `in_progress`
and `order.created` are accepted, while `Open`, `1st`, `a-b` and `o` are
refused. A number or boolean `value` is refused with `invalid_type`; write it
as an identifier string.
- `icon` and `disabled` on an option are refused by name (`unrecognized_keys`),
as the spec refuses them on object metadata. They were stripped silently
before, so no renderer ever received them from this surface.
- `description`, `default` and `visibleWhen` are now accepted as the spec
declares them, where before they were stripped.
- A bare-string `visibleWhen` is canonicalized in the PARSE OUTPUT to the
spec's envelope `{ dialect: 'cel', source }`. An authored envelope passes
through unchanged. (objectui's own form select option keeps the bare string;
this surface follows the spec.)
- A `visibleWhen` envelope without `dialect` (objectui's wire spelling
`{ source }`) is refused, as is an empty string. On the installed spec
17.4.0 a whitespace-only string is still accepted; the next spec release
refuses it.

The TypeScript declaration is unchanged. The one remaining difference between the
two faces, the `visibleWhen` envelope, is recorded in the parity ledger as an
expected divergence against the read model.
166 changes: 166 additions & 0 deletions packages/types/src/__tests__/detail-view-field-options-10296.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `DetailViewFieldSchema.options` is the spec's AUTHORING option schema, by
* reference (objectui#10296, ruling F1 on objectui#7759).
*
* Before this card the mirror carried an inline option shape that admitted a
* number or boolean `value` the declaration (`SelectOptionMetadata[]`) refuses,
* and silently STRIPPED every other key. F1 rules that a structural mirror points
* at the spec's authoring schema, ⛔ never at the declaration: the declaration here
* is the runtime READ model, which admits `disabled` and `icon` — two keys the
* spec refuses by name. The remaining difference between the two faces
* (`visibleWhen`'s envelope) is recorded as an EXPECTED DIVERGENCE in the parity
* ledgers, not repaired.
*
* Every refusal below is asserted on the issue `code` and the `path` it lands on,
* each beside an accepting control, so a schema that refuses everything cannot
* pass. On a revert to the inline shape the numeric/boolean `value` and the
* by-name refusals turn green-to-red.
*/

import { describe, it, expect } from 'vitest';
import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data';
import { DetailViewFieldSchema } from '../zod/views.zod.js';

const field = (option: Record<string, unknown>) =>
DetailViewFieldSchema.safeParse({ name: 'status', type: 'select', options: [option] });

const issuesOf = (r: ReturnType<typeof field>) =>
r.success ? [] : r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') }));

describe('DetailViewFieldSchema.options is the spec authoring option schema (objectui#10296)', () => {
it('accepts an authored option carrying every spec key (accepting control)', () => {
const r = field({
label: 'Open',
value: 'open',
description: 'Not started yet',
color: '#22c55e',
default: true,
visibleWhen: "record.kind == 'task'",
});
expect(issuesOf(r)).toEqual([]);
});

it.each([
['a number', 1],
['a boolean', true],
])('refuses %s as `value` — the spec value is a machine identifier string', (_label, value) => {
const r = field({ label: 'One', value });
expect(r.success).toBe(false);
expect(issuesOf(r)).toContainEqual({ code: 'invalid_type', path: 'options.0.value' });
});

it.each(['icon', 'disabled'])('refuses the read-model-only key `%s` BY NAME', (key) => {
const r = field({ label: 'Open', value: 'open', [key]: key === 'icon' ? 'circle' : true });
expect(r.success).toBe(false);
const issue = r.success ? undefined : r.error.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue?.path.join('.')).toBe('options.0');
expect((issue as { keys?: string[] } | undefined)?.keys).toEqual([key]);
});

it.each(['Open', '1st', 'a-b', 'o'])(
'refuses `value: %s` — the spec value is `^[a-z][a-z0-9_.]*$`, at least 2 characters',
(value) => {
const r = field({ label: 'Open', value });
expect(r.success).toBe(false);
expect(issuesOf(r).map((i) => i.path)).toContain('options.0.value');
},
);

it.each(['ab', 'a.b', 'in_progress'])('accepts `value: %s` (accepting control for the rule above)', (value) => {
expect(issuesOf(field({ label: 'Open', value }))).toEqual([]);
});

/**
* OUTPUT SHAPE, declared rather than left implicit. `stripImportedDefaults`
* removes defaults only, so the spec's expression-input PIPE survives on
* `options[].visibleWhen`: the parsed output is the canonical envelope
* whichever spelling was authored. One authored document therefore has two
* input spellings and ONE output shape here. That differs from objectui's
* own form `SelectOptionSchema`, which keeps objectui's wire and passes a
* bare string through unchanged (objectui#7735 / #8317). The contract review
* on objectui#10296 kept the spec's pipe (option A); these pins keep it
* visible.
*/
const optionOut = (visibleWhen: unknown) => {
const r = field({ label: 'Open', value: 'open', visibleWhen });
return r.success ? r.data.options?.[0] : r;
};

it('canonicalizes a bare-string `visibleWhen` to a `{ dialect: "cel", source }` envelope in OUTPUT', () => {
expect(optionOut("record.kind == 'task'")).toEqual({
label: 'Open',
value: 'open',
visibleWhen: { dialect: 'cel', source: "record.kind == 'task'" },
});
});

it('passes an authored `{ dialect, source }` envelope through as the same envelope', () => {
expect(optionOut({ dialect: 'cel', source: 'x == 1' })).toEqual({
label: 'Open',
value: 'open',
visibleWhen: { dialect: 'cel', source: 'x == 1' },
});
});

it('refuses an envelope without `dialect` — objectui wire spelling, not the spec envelope', () => {
const r = field({ label: 'Open', value: 'open', visibleWhen: { source: 'x == 1' } });
expect(r.success).toBe(false);
expect(issuesOf(r)).toContainEqual({ code: 'invalid_union', path: 'options.0.visibleWhen' });
});

it('refuses an empty-string `visibleWhen`', () => {
const r = field({ label: 'Open', value: 'open', visibleWhen: '' });
expect(r.success).toBe(false);
expect(issuesOf(r).map((i) => i.path)).toContain('options.0.visibleWhen');
});

/**
* ⚠️ Pinned as it stands on the INSTALLED `@objectstack/spec` 17.4.0: a
* whitespace-only string is ACCEPTED and canonicalized. objectstack `main`
* already moved `visibleWhen` to `EvaluatedExpressionInputSchema`, whose
* string arm refuses blank strings, so the next spec bump TIGHTENS this and
* turns this test red on purpose. When it does, flip it to a refusal.
*/
it('accepts a whitespace-only `visibleWhen` on spec 17.4.0 (the next spec bump refuses it)', () => {
expect(optionOut(' ')).toEqual({
label: 'Open',
value: 'open',
visibleWhen: { dialect: 'cel', source: ' ' },
});
});

/**
* TRIPWIRE for the spec-version gate in `zod-mirror-parity.test.ts`
* (`SpecEnvelopeAdmitsSourceless`). On the installed spec 17.4.0 the envelope's
* `source` is OPTIONAL in the static input type, and at runtime `ast` alone
* satisfies its refine (`{ dialect }` with neither is refused). So an envelope
* with no `source` parses, which the declaration's wire refuses. That is why
* `options` and its two containers are ledgered WIDER. objectstack `main` requires
* a non-blank `source`. When the pin bump turns this red, delete the gated rows,
* their `WIDER_ARMS` entries and the gate, then flip this to a refusal.
*/
it('accepts a source-less `{ dialect, ast }` envelope on spec 17.4.0 (the gated WIDER rows rest on this)', () => {
expect(optionOut({ dialect: 'cel', ast: { kind: 'ident' } })).toEqual({
label: 'Open',
value: 'open',
visibleWhen: { dialect: 'cel', ast: { kind: 'ident' } },
});
});

it('gives the same verdict as the spec schema itself, over a spread of options', () => {
const samples: Record<string, unknown>[] = [
{ label: 'Open', value: 'open' },
{ label: 'Open', value: 'Open' },
{ label: 'Open', value: 'o' },
{ label: 'Open', value: 'in_progress', color: 'blue' },
{ value: 'open' },
{ label: 'Open', value: 'open', stray: 1 },
];
for (const option of samples) {
expect(field(option).success, JSON.stringify(option))
.toBe(SpecSelectOptionSchema.safeParse(option).success);
}
});
});
111 changes: 100 additions & 11 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,14 @@
* a delta to this number; count the registry. Nothing asserts it against a written
* one, so this line is prose and can rot; the pin that cannot is the one
* comparing the two halves to each other.
* - **48 entries** in `KnownDrift`, **88 keys** across them — 49 / 90 until
* - **50 entries** in `KnownDrift`, **92 keys** across them — 48 / 88 until
* objectui#10296 pointed `views.zod.ts#DetailViewFieldSchema`'s `options` at the
* spec's AUTHORING `SelectOptionSchema` (ruling F1 on objectui#7759): that pair
* and `views.zod.ts#DetailViewSectionSchema` are NEW entries with one key each,
* plus `fields` and `sections` on the EXISTING `views.zod.ts#DetailViewSchema`
* entry — the two containers carry those field elements. One
* EXPECTED DIVERGENCE against the runtime read model the declaration names, not
* a defect — the two entries' docblocks carry the reading. It was 49 / 90 until
* objectui#10293 settled `form.zod.ts#CalendarSchema`'s `defaultValue` and `value`
* under objectui#7759 D1-(iii): the declaration gained the ISO string the mirror
* accepts and gave up the `Date[]` arm the mirror refused, so both keys measure
Expand Down Expand Up @@ -601,7 +608,7 @@
*
* ## KNOWN_DRIFT is a ratchet, not a waiver
*
* 48 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is
* 50 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is
* pinned to its EXACT drifted key set, so the entry fails when new drift appears on
* that mirror AND when the recorded drift is fixed — a stale entry cannot rot
* quietly. Correcting them is not one change: the pairs below split into DISJOINT
Expand Down Expand Up @@ -2248,8 +2255,31 @@ interface KnownDrift {
* `DetailView`'s `schema.onNavigate` / `schema.onAddComment` by identity —
* exactly as it does under `'detail'`, whose twin arm refused both by name
* with objectui#7804. The TS face keeps the callable on all three.
*
* `fields` and `sections` JOINED with objectui#10296: they carry
* `DetailViewFieldSchema` elements, so that pair's EXPECTED DIVERGENCE on
* `options` (the entry below) is measured here one level up. Same cause,
* not a separate finding.
*/
'views.zod.ts#DetailViewSchema': 'onBack' | 'onNavigate' | 'onAddComment' | 'fields' | 'sections';
/**
* EXPECTED DIVERGENCE, read model (objectui#10296, ruling F1 on objectui#7759).
* The mirror is the spec's AUTHORING `SelectOptionSchema` by reference; the
* declaration is `SelectOptionMetadata[]`, the runtime READ model. The one member
* the two faces spell differently is `visibleWhen`: the spec's input envelope
* requires `dialect`, the read model carries objectui's wire (objectui#2212),
* where `dialect` is optional and `source` required. ⛔ Not repaired by pointing
* the mirror at the declaration (it would admit `disabled` / `icon`, which the
* spec refuses by name). Also in `WiderThanDeclared`, for the other half.
*/
'views.zod.ts#DetailViewSchema': 'onBack' | 'onNavigate' | 'onAddComment';
'views.zod.ts#DetailViewFieldSchema': 'options';
/**
* objectui#10296: `fields` carries `DetailViewFieldSchema` elements, so the
* EXPECTED DIVERGENCE on that pair's `options` (the entry above) is measured
* here one level up — the same reading as `fields` / `sections` on
* `views.zod.ts#DetailViewSchema`. Same cause, not a separate finding.
*/
'views.zod.ts#DetailViewSectionSchema': 'fields';
}

/* ── The measured unmirrored-declared ledger (objectui#6058) ────────────────── */
Expand Down Expand Up @@ -3161,7 +3191,14 @@ interface WiderThanDeclared {
// The renderer places `schema.content` RAW in a React child position, so the list arm parsed
// green and then failed to render. objectui#10280 NARROWED the MIRROR to the declaration, and
// the entry is GONE.
/** CONCRETE: an inline option shape against the named `SelectOptionMetadata`. */
/**
* EXPECTED DIVERGENCE, read model (objectui#10296, ruling F1): the mirror is the
* spec's authoring `SelectOptionSchema`, whose `visibleWhen` envelope admits an
* object with no `source`, which the read model's wire refuses. Spec-version-gated
* by `SpecEnvelopeAdmitsSourceless`. See the `KnownDrift`
* entry for this pair. (The inline option shape that stood here, admitting a
* number or boolean `value`, is gone.)
*/
'views.zod.ts#DetailViewFieldSchema': 'options';
/** SCHEMA-NODE. (`tabs` left under objectui#7760; `fields` and `sections` did not.) */
'views.zod.ts#DetailViewSchema': 'fields' | 'sections';
Expand Down Expand Up @@ -3430,6 +3467,59 @@ export const assertionUnmirroredMatchesLedger: never = 0 as unknown as {
[K in MirrorKey]: ReconcileAgainstLedger< K, UnmirroredOf< K >, RecordedUnmirrored< K > >;
}[MirrorKey];

/**
* SPEC-VERSION GATE for the `options` row and its two containers (objectui#10296): does the installed spec's
* `visibleWhen` input envelope admit an object with NO `source`?
*
* `views.zod.ts#DetailViewFieldSchema::options` reads the spec's `SelectOptionSchema`
* by reference, so its WIDER verdict follows the spec that is installed. On the
* pinned `@objectstack/spec` 17.4.0 the envelope's `source` is optional in the static
* input type (at runtime `ast` alone satisfies its refine), so an envelope with no
* `source` is admitted and the declaration (whose wire requires `source`) refuses it:
* WIDER, recorded. On objectstack `main` the key is
* `EvaluatedExpressionInputSchema`, whose envelope REQUIRES `source`, so the mirror is
* no longer wider there (the `KnownDrift` half, `dialect` required, holds on both).
* The Spec Main Shape Gate compiles this file against `main`, so the ledger must
* hold on both.
*
* Read off the MIRROR's own input face, not an import of the spec, so it measures
* exactly the schema the row is about. The ledger entry stays a literal (the census
* parses it). Only the reconciliation consults this, and only for the rows in
* `SpecEnvelopeGatedWider` below: on 17.4.0 they must be measured WIDER exactly as
* before, and on `main` they must measure absent, so neither side is waived. ⛔ At
* the pin bump this reads `false` for good. Then delete those rows, their
* `WIDER_ARMS` entries and this gate together.
* The runtime tripwire in `detail-view-field-options-10296.test.ts` goes red at
* that bump to say so.
*/
type DetailViewOptionVisibleWhenInput = NonNullable<
NonNullable< MirrorInputOf< 'views.zod.ts#DetailViewFieldSchema', 'options' > >[number]['visibleWhen']
>;
export type SpecEnvelopeAdmitsSourceless =
{ dialect: 'cel' } extends DetailViewOptionVisibleWhenInput ? true : false;

/**
* The rows that gate withholds on a spec whose envelope requires `source`. Measured
* against objectstack `main` `e8f163fc3a62`, not assumed: the two container pairs
* carry `DetailViewFieldSchema` elements, and there their WIDER reading is gone too.
* Their only wider member was `options` (on the base commit, the inline shape's
* number/boolean `value`; on 17.4.0, the source-less envelope). They are ledgered
* SCHEMA-NODE, but that class names the arm, not the cause, so they leave with it.
*/
type SpecEnvelopeGatedWider = {
'views.zod.ts#DetailViewFieldSchema': 'options';
'views.zod.ts#DetailViewSchema': 'fields' | 'sections';
'views.zod.ts#DetailViewSectionSchema': 'fields';
};

/** What `WiderThanDeclared` records for a pair, with the spec-version gate above applied. */
export type WiderRecorded< K extends MirrorKey > =
K extends keyof WiderThanDeclared
? SpecEnvelopeAdmitsSourceless extends true
? WiderThanDeclared[K]
: Exclude< WiderThanDeclared[K], K extends keyof SpecEnvelopeGatedWider ? SpecEnvelopeGatedWider[K] : never >
: never;

/**
* The THIRD direction: every pair's WIDER key set equals what `WiderThanDeclared`
* records for it — `never` for the pairs with no entry.
Expand All @@ -3446,11 +3536,7 @@ export const assertionUnmirroredMatchesLedger: never = 0 as unknown as {
* @object-ui/types type-check` is the gate that reads it.
*/
export type WiderLedgerMismatch = {
[K in MirrorKey]: ReconcileAgainstLedger<
K,
WiderOf< K >,
K extends keyof WiderThanDeclared ? WiderThanDeclared[K] : never
>;
[K in MirrorKey]: ReconcileAgainstLedger< K, WiderOf< K >, WiderRecorded< K > >;
}[MirrorKey];

export const assertionWiderMatchesLedger: never = 0 as unknown as WiderLedgerMismatch;
Expand All @@ -3468,8 +3554,8 @@ export const assertionWiderMatchesLedger: never = 0 as unknown as WiderLedgerMis
*/
export type WiderLedgerKeyDrift = {
[K in MirrorKey]:
| Exclude< WiderOf< K >, K extends keyof WiderThanDeclared ? WiderThanDeclared[K] : never >
| Exclude< K extends keyof WiderThanDeclared ? WiderThanDeclared[K] : never, WiderOf< K > >;
| Exclude< WiderOf< K >, WiderRecorded< K > >
| Exclude< WiderRecorded< K >, WiderOf< K > >;
}[MirrorKey];

export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderLedgerKeyDrift;
Expand Down Expand Up @@ -4067,6 +4153,9 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [
// Membership here is what re-derives the pair's one remaining
// `UnmirroredDeclared` key (`listViews`) into the split's SPEC-DERIVED half.
'objectql.zod.ts#ObjectViewSchema',
// objectui#10296: `options` is the spec's authoring `SelectOptionSchema` by
// reference (ruling F1 on objectui#7759).
'views.zod.ts#DetailViewFieldSchema',
];

/* ── Runtime: the population is closed ──────────────────────────────────────── */
Expand Down
Loading
Loading