Skip to content

Commit 16d241a

Browse files
feat(spec)!: object-gantt markers, object-timeline mapping and both forms' fields take the shape each block reads; five objectui-held contracts reported as forks (#21464, S-objectui-held) (#21699)
Part of #21464 Clause-②: yes (narrowing) ## What this does The S-objectui-held stage of the `ComponentPropsMap` `z.unknown()` close-out, per the claim `5976829576`, the stage-5 landing record `5976140848`, the seat answers `5963787404` and `5969669440`, the stage-5 ACCEPT `5975923386` and `domain:devx`'s pointer `5974144504`. The stage took its own value census first (below), then wrote down and typed the contracts that have one viable shape. Under the stop valve, it holds the five contracts (seven ledger lines) that have more than one shape, records them in the enumeration pin, and reports them on the card as forks. Read points are at the `.objectui-sha` pin `ab1879721595`. Every cited reader is byte-identical at objectui `main` `94985a92ba`; the two cited `@object-ui/types` declaration files changed elsewhere, and their cited lines are identical. | member | was | now | read point at `ab1879721595` | |:--|:--|:--|:--| | `object-gantt` `markers` | `z.array(z.unknown())` | strict `{ date, label?, color? }` entries, `date` a string (module-private factory `objectGanttMarker()`), with aliases `title` / `text` / `name` to `label` and `colour` / `stroke` to `color` | `ObjectGantt.tsx:2505` hands `schema.markers` to `GanttView`, which re-bases `date` (`GanttView.tsx:913-924`), drops a marker whose date does not parse or falls outside the range (`:2394-2413`), and draws `label` and `color` (`:2407`, `:4083-4100`, SVG `:3320-3332`). objectui declares the authored marker as exactly this shape (`types/src/objectql.ts:3787-3794`, zod mirror `types/src/zod/objectql.zod.ts:2738-2746`) | | `object-timeline` `mapping` | `z.unknown()` | strict `{ title?, date?, description?, variant? }`, each a field name (module-private `ObjectTimelineMappingSchema`), with aliases from the flat `titleField` / `dateField` / `startDateField` / `descriptionField` / `variantField` | `ObjectTimeline.tsx:551` (`title`), `:576` (`date`), `:578` (`description`), `:579` (`variant`). objectui declares the same four strings on the component prop (`:254-259`) and in `TimelineMappingSchema` (`:144-149`) | | `object-form` `fields` | `z.array(z.unknown())`, held at stage 3 | field-name strings (`formFieldNameList()`). A `{ name }` or `{ field }` object entry is refused with what to write instead | `ObjectForm.tsx:961-981`, `flatFields.ts:68-79`. objectui declares `ObjectFormSchema.fields: string[]`. The form still draws a STORED `{ name }` entry by its name (`:972`, `flatFields.ts:72`) and skips any other object entry with a warning (`:978`) | | `object-master-detail-form` `fields` | `z.array(z.unknown())`, held at stage 3 | the same list (one schema, pinned) | handed to the parent form verbatim, `MasterDetailForm.tsx:1693` | **The `fields` hold's exit.** objectstack-ai/objectui#11550's triage ruling (`5969880008` there) retired the `{ name }` entry from objectui's authoring faces, and its closing paragraph says this card "types object-form and master-detail `fields` as names once its `.objectui-sha` covers this landing". The pin covers both code landings: objectui `806f327` (the fixtures respelled) and `dbd1081` (the registration descriptions and the warning text). `merge-base --is-ancestor` exits 0 for each. The guide's own respelling (`0a53c67`) is on objectui `main` only. Measured at both trees, the only refused values are fixtures probing the read (census below). **No new export.** The three new shapes are module-private. No new member carries a default or a transform, so each parsed value is the authored one. The gantt marker is a factory the row calls, as `masterDetailDetailEntry()` is, and not a `lazySchema` proxy. A bare proxy used as an array element is never forced by `alias-integrity.test.ts`'s walk, which skips def values that are functions. The first full-suite run caught exactly that: `alias integrity — coverage` listed `ui/component.zod.ts:6611 (this object-gantt marker)` as unreached. The factory form is green. ## Held as forks (the stop valve) Each of these has two or more viable spec shapes that no existing ruling or seat answer decides. Each keeps its hold. The enumeration pin's ledger now files each under a new `fork` stage that names the shapes, and §2 checks that every `fork` line names at least two different shapes. The emptied `objectui-held` stage leaves `STAGES`. Each fork is reported in this stage's `os-dev-report` `open_questions` with its census. - **`object-metric` `drillDown.report`: direction A's premise is disproved by measurement, so it is reported as a fork and takes no shape, as the ACCEPT `5975923386` directs.** Premise (1) holds: the one drawn report the census finds (`objectMetricDrillDownMembers-8071.test.tsx:282`, a dataset-bound summary report with `name` and `label`) parses through `ReportSchema`. Premise (2), that `isDatasetBoundReport`'s two arms are exactly what `ReportSchema` admits, fails in both directions (measured by parse, this branch's source): | value | drawer (`isDatasetBoundReport`) | `ReportSchema` | |:--|:--|:--| | `{ name, label, type: 'joined', blocks: [{ name }] }`, no block binds a dataset | lists the records | ACCEPT | | `{ dataset: 'sales' }`, no `name` / `label` / `values` | draws the report | REFUSE | | `{ name, label, dataset: 'sales' }`, no `values` | draws the report | REFUSE (`custom` at `dataset`) | | `{ name, label, type: 'joined', dataset, blocks: [...] }` | draws the report | REFUSE (`custom` at `dataset`) | The first row is the silent-fallback class this card closes. It comes from `ReportSchema` itself: a joined block's `dataset` is optional, although the schema's own refinement comment says each block is "dataset-bound". `objectstack validate` passes such a report with exit 0, while a bound block naming an undeclared dataset is refused (`chart-dataset-unknown`). See Acceptance notes. - **`object-form` `customFields`**: objectui's runtime `FormField` (`types/src/form.ts:1770`) is open (an index signature beside forty-five members, `:1906`), and eight of its members are the grid widget's snake_case keys (`min_rows`, `allow_add`, …), which this package's camelCase rule for config keys does not admit as written. - **`object-form` and `object-master-detail-form` `sections`**: objectui#11550 KEPT the inline runtime field in a section ("shape 3", `sectionFields.ts:369-370`, declared `ObjectFormSection.fields: (string | FormField)[]`), so this waits on `customFields`'s fork for its third entry arm. - **`object-timeline` `items`**: a feed entry's `content` is child schema nodes (a slot position the page walks would judge, or an opaque member), and the arm an entry must match is chosen by the parent's `variant` (objectui's row-level refinement, or a plain union of the arms). - **`action:group` / `action:menu` members**: measured from the reads, the key set is mostly `action:button`'s, keyed by `type`. It also takes keys the rows leave undecided: `outcomeMessages` (recorded on `action:button` as "a contract decision, not a pin re-measure"), a member `className`, the member's own `properties.params` bag (`static-params.ts:142-160`) and `endpoint` (refused on the rows since #21005). The `object-kanban` `conditionalFormatting` hold (`held-for-decision`) is not in this stage's member list and is untouched. Its carrier, objectstack-ai/objectui#11522, is now in state `completed` (2026-10-03), so its exit may be readable. That is for the seat. ## The census A writer is a value written on the block: a page-component node (an object literal naming the type, flat or in `properties`, a literal annotated or asserted with the block's objectui type, a direct parse through the row), the block's React component with the member as a prop or inside `schema={{…}}`, or the argument of a same-file helper that mounts one (helpers found through a block literal or the block's JSX in their body, including one spread from a const; positional parameters resolved at every call site). Values resolve through same-file constants and spreads. Instrument: a TypeScript-AST walk over `.ts` `.tsx` `.js` `.jsx` `.mjs` `.cjs` `.mts` `.json` `.yaml` `.md` `.mdx` (fenced code parsed). For the typed members, a text search for the member key in every file naming the block found what the walk does not reach, and each hit was read by hand. Cross-check: the walk reproduces stage 5's `drillDown` population exactly (26, 25 static). | corpus | `markers` | `mapping` | `fields`, both forms | |:--|:--|:--|:--| | objectstack `7d0781482d` (`examples/`, `packages/`, `content/`, `skills/`, `apps/`, `docs/`, 9306 files) | 0 | 1 · parses | 4, all master-detail · all parse | | objectui pin `ab1879721595` (whole tree, 10267 files) | 9 · 8 parse · 1 refused | 9 · all parse | 73 · 55 parse · 7 refused · 11 not static | | objectui `main` `94985a92ba` (7564 files; the release deleted 2726 changesets) | same 9 | same 9 | 73 · 56 parse · 6 refused · 11 not static | | hotcrm `4054ec2680`, cloud `b2d7a7f6f8` | 0 | 0 | 0 | **objectstack writers:** `packages/spec/src/ui/component-element-navigation-17987.test.ts` (`mapping: { title, variant }`). For master-detail `fields`: the showcase's `project-workspace.page.ts`, `content/docs/protocol/objectui/layout-dsl.mdx`, and two test copies (`packages/lint/src/validate-component-props.test.ts`, `packages/spec/src/ui/component.test.ts`). All are field names. **objectui refused values, under the writer test (`5966636964`):** | refused value | where | drawn or probe | |:--|:--|:--| | `markers: [{ date: 5 }]` | `types/src/__tests__/gantt-declared-keys.test.ts:168` | compile-time refusal probe (`@ts-expect-error`) | | `fields: [{ field: 'note' }]`, `[{ field: 'sent_at' }]` | `plugin-form/src/__tests__/objectFormFieldsMembers-8071.test.tsx:140`, `:199` | probes: the form skips the entry with a named warning | | `fields: [{ name: 'note' }]` ×2 | `objectFormFieldsMembers-8071.test.tsx:177`, `:182` | probes: objectui's own mirror refuses it; a STORED one still draws | | master-detail `fields: [{ field: 'note' }]` | `topLevelFieldsWarnCoverage-8847.test.tsx:110` | probe: the warning fires | | master-detail `fields: [{ name: 'note' }, 'status']` | `topLevelFieldsWarnCoverage-8847.test.tsx:258` | probe: a STORED entry still draws | | `fields: [{ name, label, type, required }, …]` (pin only) | `skills/objectui/guides/page-builder.md` | the retired guide example, respelled to names on objectui `main` | Not writers: five `markers` arrays mount `GanttView`, the runtime chart, directly (`GanttView.virtual.test.tsx` ×2, `GanttView.dateOnlyZone-10866.test.tsx`, the plugin README's `GanttView` example, `demo/main.tsx`). Fourteen `fields` matches are object definitions or permission maps whose own `fields` key the walk read as the block's (`ObjectForm.effectiveOps` ×3, `ObjectForm.managedEdit` ×3, `ObjectForm.mobileFullscreen` ×3, `drawerFormSectionDescription-9834`, `objectFormNumericStep-9574` ×2, `LineItemsPanel.fieldWriteGate-10163` ×2). The 11 that are not static are run-time hand-offs (`AppContent`, `useActionModal`, `RecordFormPage`, `ViewPreview`, `StudioDesignSurface`, `DrawerForm`, `EmbeddableForm`, `ModalForm`, `ObjectForm.tsx:387`, `MasterDetailForm.tsx:1693`, `ObjectView`). None passes through the component-props gate. **The forks' census**, both objectui trees identical. objectstack, hotcrm and cloud author none of these, except one `items` and three `sections` (strings) in objectstack. - `drillDown.report`: 3 values. The one drawn report parses through `ReportSchema`; the two `it.each` probes (`{ note }`, the retired `objectName` form) are not drawn and are refused. - `customFields`: 27 values (24 static, 31 entries), every entry keyed `name` with `label` and `type`. One carries `group` and one `defaultValue`, keys outside `FormField`'s forty-five that ride its index signature. No entry writes a snake_case grid key. - `sections`: object-form 165 values (99 static); 192 string entries, 3 `{ field }` entries and 7 shape-3 inline runtime fields (`plugin-form/README.md`'s wizard, `submitTargetRefusal.test.tsx` ×3). Master-detail: 13 values, strings only. - `items`: 14 values (11 static): 8 feed entries and 5 gantt rows, none with `content`. - `action:group` / `action:menu`: 40 / 19 values (12 / 6 static, 15 / 6 members). The keys used are `name`, `label`, `type`, `locations`, `target`, `bodyExtra`, `bodyShape`, `objectName`, and one `autoTrigger` in a host auto-trigger test. None uses an undecided key. ## Changes - `packages/spec/src/ui/component.zod.ts`: `objectGanttMarker()` (with the reason it is a factory), `ObjectTimelineMappingSchema`, `formFieldNameRefusal` and `formFieldNameList()` with their docblocks. The four members are typed. The held members' docblocks now record each fork, re-read at `ab1879721595`: the drill `report` with the measured disagreement, `customFields`, both `sections`, `items` and `actionMemberList`. The gantt and timeline row docblocks no longer say `markers` / `mapping` stay open. - `packages/spec/src/ui/component-objectui-held-typed-members.pin.test.ts` (new): §1 15 byte-identical parses of the census writers, plus the absent-member case; §2 17 refusals by `code` and path, plus the two prescriptions, the no-prescription control and the alias pointers; §3 each shape's exact member set, and the two forms' `fields` answering identically, messages included; §4 the three D3 ids. - `packages/spec/src/ui/component-props-unknown-members.pin.test.ts`: four lines leave the ledger (`markers[]`, `mapping`, both `fields[]`). Seven become `fork` lines naming their shapes (`customFields`, both forms' `sections`, `items`, both action containers' members, and the drill `report`, which was `objectui-held`). The `objectui-held` stage is replaced by `fork`, and a §2 case is added. - `packages/spec/src/ui/component-form-family-typed-members.pin.test.ts`: the header records this stage's outcome. - `packages/spec/src/migrations/entries/semantic/18.ui-object-gantt-markers-typed.ts`, `18.ui-object-timeline-mapping-typed.ts`, `18.ui-object-form-fields-names-typed.ts` (new), and `packages/spec/src/migrations/registry.ts`: three step-18 rationale fragments at orders 74, 75 and 76 (73 is the last taken on `main`), inserted where each id sorts. The semantic region is regenerated by `gen:migration-registry`. No D2 conversion and no `RETIRED_KEYS_BY_MAJOR` row: page-component `properties` is not parsed on the save or load path, and the refused values are nested member values. - Regenerated by `check:generated --fix`, which proved only these two stale: `content/docs/references/ui/component.mdx` and `docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md` (`ui/` 196 → 198 sites, +2 strict). - `.changeset/21464-component-props-objectui-held-typed.md`: `@objectstack/spec` `minor`, BREAKING banner, the `Clause-②` line, FROM → TO, the census, and the ADR-0087 marker registering the three ids. ## Measurements Head `723df54741`, base `7d0781482d`. Heavy runs went through `scripts/pm/os-verify-lock.sh`, and every exit code was captured before any pipe. - **Red first, on the published `@objectstack/spec@17.6.0`** (the npm tarball, `ComponentPropsMap[type].safeParse`). Each of these is ACCEPTED there: `markers: [{ date: 5 }]`, `[{ label: 'Deadline' }]`, `[{ date, title }]`; `mapping: 'subject'`, `{ titleField: 'code' }`, `{ title: 5 }`; object-form `fields: [{ name, label, required }]`, `[{ field: 'note' }]`, `[5]`; master-detail `fields: [{ name: 'note' }, 'status']`. Each is refused on this branch, pinned in §2. - **`pnpm --filter @objectstack/spec test`** at `723df54741`: exit 0, Test Files 611 passed (611), Tests 18157 passed, 1 todo, nothing skipped. An earlier run at `a88b9f3e4a` read 610 passed and 1 skipped: `root-entry-type-nameability.pin.test.ts` skips by design while `dist` is stale, and it ran here after a rebuild. - **`pnpm --filter @objectstack/spec typecheck`** at `a88b9f3e4a` (only a test comment differs at the head): exit 0, `check:test-typecheck: OK` (52 files / 246 errors / 135 signatures held). `tsc -p tsconfig.test.json --listFilesOnly` names the three touched pins. - **Build and generated artifacts**: the spec build is green, including the eager `gen:schema` pass. `check:generated` proved `check:docs` and `check:strictness-ledger` stale, `--fix` regenerated those two only, and the re-check is green. A later `turbo run build --filter=!@objectstack/docs` built 72 tasks, all successful. - **Consumers**: `pnpm --filter @objectstack/lint test` passed 119 files and 5622 tests. The 5 skipped tests read lint's own built `dist`, which this worktree had not built yet. After the turbo build, `lazy-deps`, `runtime-lazy-deps` and `validate-component-props` passed 61 of 61. `pnpm --filter @objectstack/example-showcase validate` printed "Validation passed". - **The public door, both ways** (`validateComponentProps` from lint `src` over the built spec, one block per page). These report nothing: a marker with `date` / `label` / `color`, a `{ title, variant }` mapping, form and master-detail name lists, and an arbitrary drill `report` (still held). These are reported: `markers.0.date: 5` as `component-props-invalid`; a marker `title` as `component-props-unknown-key` at `properties.markers.0.title`, its message naming `label`; `mapping: 'subject'` as `component-props-invalid`; `mapping.titleField` as `component-props-unknown-key`; a form `{ name: 'email', … }` entry as `component-props-invalid` at `properties.fields.0`, with "write `'email'`, not an object"; and a master-detail `{ field: 'note' }` entry with the section-vocabulary prescription. - **Ablation**, one leg per narrowing, on the committed tree (`a88b9f3e4a`). Each leg used `node scripts/ablation-replace.mjs` in wrap mode, inside a driver with its own `EXIT INT TERM` restore trap on the absolute path, an empty hash read as failure, and HEAD blob `1a6b65102a`: - `markers` → `z.array(z.unknown())`: anchor x1 → x0, blob → `1f6bdbd1a9`. Red: 9 failed, 86 passed. The enumeration pin's §1 received exactly `[ "object-gantt markers[]" ]` and its census-equals-ledger control read 88 against 87. The companion pin failed its five marker refusals, the alias pointer and the §3 member set. - `mapping` → `z.unknown()`: blob → `2ee8a60e25`. Red: 8 failed, 87 passed. §1 received `[ "object-timeline mapping" ]`, 88 against 87, plus the four mapping refusals, the alias pointer and §3. - object-form `fields` → `z.array(z.unknown())`: blob → `5211b43913`. Red: 9 failed, 86 passed. §1 received `[ "object-form fields[]" ]`, 88 against 87, plus the four refusals, both prescriptions and the §3 two-forms identity. - Restore after every leg: blob equals HEAD and `git diff HEAD` is empty. The pins import `./component.zod` from source, so no build or `dist` preflight sits between mutation and run. - **Derived gates** at `723df54741`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 114 commands, all exit 0. `--ran` printed "114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN". The first pass reddened only `check-spec-docblock-symbol-anchors` (and its `--self-test`) on a line anchor in the new pin's comment; it now cites the file. Every command was re-run on the final head. - **`check-widening-tells`** over the branch diff: `--declaration no` exits 4 with seven T1 tells, every one a key inside a former `z.unknown()` bag (the marker's three, the mapping's four). `--declaration yes` exits 0. - **Narrowed lint**: `eslint --no-inline-config --format json` over the 8 changed TypeScript files reports 8 files, 0 errors, 0 warnings. Population comes from eslint's own `--print-config`: those 8 resolve a config, and the changeset, `component.mdx` and the counts page resolve `undefined`. Invariance: `eslint.config.mjs` never enables type-aware linting (its own text, about line 327), so this diff cannot move a verdict on an untouched file. The full `pnpm lint` is CI's. - **NOT MEASURED**: the Console Pin Gate, the Dogfood Regression Gate and the full `pnpm lint`, because they are CI-owned; objectui was read at the pin and at `main`, not built against this spec. CI on this PR was not waited on. ## Acceptance notes - **`ReportSchema` admits a joined report with no dataset-bound block.** `objectstack validate` exits 0 on `reports: [{ name, label, type: 'joined', blocks: [{ name, label }, { name, label }] }]`. The same report with a bound block naming an undeclared dataset is refused by `chart-dataset-unknown`. objectui's report renderer sends it past `isDatasetReport` (`plugin-report/src/DatasetReportRenderer.tsx:208-214`) to the pre-9.0 presentation bridge (`ReportRenderer.tsx:108`), which issues no query. The schema's own refinement comment says a joined report "carries its data on `blocks` (each block dataset-bound)". This is reported in the dev report for the seat to file, and it is the cheapest route out of the `report` fork. - **`mapping.title` / `mapping.date` are second spellings** of `timeline.titleField` / `timeline.startDateField`, read between those and the flat fallbacks. They are typed here as objectui declares them; whether to retire them is a different question. Noted, not filed. - **A marker `date` is any string**, as objectui declares it and as the same row's `holidays` / `minDate` / `maxDate` are. An unparseable date is still accepted and draws no line. Noted, not filed. - **objectui's next `@objectstack/spec` bump**: its block schemas take these rows by reference (`propsBag` over `stripImportedDefaults(...)`), but no objectui test parses a refused value through them. The refused values are fixtures that mount the component or parse objectui's own mirror. objectui source reads none of the four members' narrowed types. --- _Generated by [Claude Code](https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f623e2f commit 16d241a

11 files changed

Lines changed: 921 additions & 99 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an `object-gantt` page block's `markers`, an `object-timeline` page block's `mapping`, and the top-level `fields` of the `object-form` and `object-master-detail-form` page blocks take the shape each block reads instead of any value (#21464)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered ui-object-gantt-markers-typed, ui-object-timeline-mapping-typed, ui-object-form-fields-names-typed -->
10+
11+
**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the rows: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path.
12+
13+
**`@objectstack/spec`**
14+
15+
- **`object-gantt` `markers` takes `{ date, label?, color? }` entries.** Its entries were `z.unknown()`, because the marker contract lived only in objectui: a marker with no `date`, a numeric `date` or a misspelled member passed, and the chart drew no line, or drew it with no label and in the default colour. The spec now declares objectui's own authoring declaration of a marker — `date` an ISO date or date-time string, `label` the text drawn against the line, `color` any CSS colour — closed, and the row takes it. A marker `title`, `text` or `name` is pointed at `label`, and a `colour` at `color`.
16+
- **`object-timeline` `mapping` takes `{ title?, date?, description?, variant? }`**, each a field name. It was `z.unknown()`, for the same reason: a bare field name, a non-string binding or a misspelled member (`titleField` inside `mapping`) passed, and the rail drew the default field. The spec now declares objectui's own declaration of the binding record, closed. `titleField`, `dateField` / `startDateField`, `descriptionField` and `variantField` written inside `mapping` are pointed at the member they meant.
17+
- **`object-form` and `object-master-detail-form` `fields` take field names.** The top-level list was an array of `z.unknown()`, held while the form drew a `{ name }` entry its page-builder guide taught, with a `label`, `type` and `required` it silently dropped. objectui has since retired that entry from every authoring face (the form still draws a stored one by its name), so both rows take field-name strings, objectui's own declaration of the member. A `{ name: 'email' }` entry is refused with `write 'email'` and where a per-form override goes; a `{ field: 'email' }` entry — the `sections[].fields` vocabulary, which the form skips at the top level — is refused with the same name and that pointer.
18+
- **Not narrowed, and still accepting any value:** the `object-metric` drill-down's `report`, `object-form` `customFields`, both forms' `sections`, `object-timeline` `items` and the members of `action:group` / `action:menu`. Each contract still lives in objectui and has more than one viable spec shape that no ruling decides yet; each is typed once one is chosen.
19+
- **`ObjectGanttProps`, `ObjectTimelineProps`, `ObjectFormProps` and `ObjectMasterDetailFormProps`** carry these types on the four members instead of `unknown`. No new member carries a default, so each parsed value is the authored one.
20+
21+
## FROM → TO
22+
23+
| you wrote | write instead |
24+
|:--|:--|
25+
| `object-gantt` `markers: [{ date: 5 }]` | `markers: [{ date: '2026-07-01' }]` — an ISO date or date-time string |
26+
| `object-gantt` `markers: [{ label: 'Freeze' }]` | give it a `date`: `[{ date: '2026-07-01', label: 'Freeze' }]` |
27+
| `object-gantt` `markers: [{ date: '2026-07-01', title: 'Freeze', colour: 'red' }]` | `[{ date: '2026-07-01', label: 'Freeze', color: 'red' }]` |
28+
| `object-timeline` `mapping: 'subject'` | `mapping: { title: 'subject' }` — name the member the field binds |
29+
| `object-timeline` `mapping: { titleField: 'subject', variantField: 'status' }` | `mapping: { title: 'subject', variant: 'status' }` |
30+
| `object-form` `fields: [{ name: 'email', label: 'Email', required: true }]` | `fields: ['email']`, with the label and `required` on the object field or on a `sections[].fields` entry |
31+
| `object-form` `fields: [{ field: 'email' }]` | `fields: ['email']`, or move the entry into a section's `fields` |
32+
| `object-master-detail-form` `fields: [{ name: 'note' }, 'status']` | `fields: ['note', 'status']` |
33+
34+
The one-line fix: write each member as the table above shows. No conversion is registered: a misspelled marker or mapping member has no rewrite that says which member the author meant, and a form already draws a stored `{ name }` entry by its name, while an override written beside it has nowhere to go but a section — the D3 entries `ui-object-gantt-markers-typed`, `ui-object-timeline-mapping-typed` and `ui-object-form-fields-names-typed` carry that judgment.
35+
36+
## Who is affected, measured
37+
38+
A writer is a value written on the block: a page-component node (an object literal naming the type, flat or in its `properties` bag, a literal annotated with the block's type, a direct parse through the row), the block's React component with the member as a prop or inside `schema={{…}}`, or the argument of a local test helper that mounts one (positional helper parameters resolved at every call site). Values resolve through same-file constants. Each static value was parsed through the row; a text search for each member key beside the block's name found the writers the walk does not reach, and each was read by hand.
39+
40+
- **objectstack** at `7d0781482d`, over `examples/`, `packages/`, `content/`, `skills/`, `apps/` and `docs/`: no `markers` and no `object-form` `fields`; one `mapping` (this package's own navigation test, `{ title, variant }`) and four `object-master-detail-form` `fields` (the showcase's project workspace, the objectui layout DSL page, and two test copies), all field names. All parse.
41+
- **objectui** at the `.objectui-sha` pin `ab1879721595` and at `main` `94985a92ba` (every read point identical between the two), every value a test fixture, a document or a run-time hand-off:
42+
- `markers`: 9 values, 8 parse. The refused one is objectui's own compile-time probe that a numeric `date` is refused (`gantt-declared-keys.test.ts`). Five more mount `GanttView`, the runtime chart, directly rather than the block, and are not writers of this member.
43+
- `mapping`: 9 values (the timeline inputs test and the absent-date-axis refusal test), all parse.
44+
- `fields`, both forms: 73 values at `main` — 56 parse, 11 are run-time hand-offs that are not static, and the 6 refused are fixtures probing the read: three `{ field }` entries asserting the form skips them with a warning, a `{ name }` entry asserting objectui's own mirror refuses it, and two `{ name }` entries asserting a stored one still draws. At the pin a seventh is refused: the page-builder guide's `{ name, label, type, required }` example, respelled to names on objectui `main`. (Fourteen more matches are object definitions or permission maps whose own `fields` key the walk read as the block's, and are not writers.)
45+
- **hotcrm** at `4054ec2680` and **cloud** at `b2d7a7f6f8`: no writer of any of the four members.
46+
- **Deployed metadata** was not measured.

‎content/docs/references/ui/component.mdx‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ Sort field and direction pair
523523
| **formType** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional | Form presentation |
524524
| **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` |
525525
| **columns** | `number` | optional | Number of field columns (multi-column forms), honoured under either `layout` |
526-
| **fields** | `any[]` | optional | Limit/order the fields shown |
526+
| **fields** | `string[]` | optional | Field names to draw, in order — bare names selecting from the object's fields and from `customFields`. A `{ name }` or `{ field }` object entry is refused: a per-form label or required override goes on a `sections[].fields` entry |
527527
| **customFields** | `any` | optional | Custom field definitions merged into the generated set |
528528
| **sections** | `any[]` | optional | Form sections (`{ label, description?, fields }` — wizard steps / tab panes) |
529529
| **title** | `string \| Record<string, string>` | optional | Form title |
@@ -596,7 +596,7 @@ Sort field and direction pair
596596
| **holidays** | `string[]` | optional | Additional non-working dates for the working calendar, ISO `yyyy-mm-dd` strings; folded into a Set for the duration math |
597597
| **persistLayout** | `boolean` | optional | Opt OUT of layout and filter-chip persistence — only an explicit `false` disables it; the storage key is `objectName:viewName` |
598598
| **viewName** | `string` | optional | Layout-persistence scope, the second half of the `objectName:viewName` storage key (renderer default `'default'`) |
599-
| **markers** | `any[]` | optional | Extra vertical reference lines drawn like the Today marker (`{ date, label?, color? }`) |
599+
| **markers** | `{ date: string; label?: string; color?: string }[]` | optional | Extra vertical reference lines drawn like the Today marker — each `{ date, label?, color? }`: `date` places the line (a date outside the drawn range draws none), `label` is drawn against it, `color` paints it |
600600
| **criticalPath** | `boolean` | optional | Seed the critical-path highlight ON; the toolbar toggle stays available either way |
601601
| **showBaselines** | `boolean` | optional | Render the planned-vs-actual baseline bars — ON unless an explicit `false` disables it |
602602
| **readOnly** | `boolean` | optional | Disable every write path on this gantt and lock the record drawer |
@@ -696,6 +696,14 @@ Sort field and direction pair
696696
| **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. |
697697
| **width** | `string \| number` | optional | [DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the `size` bucket. |
698698

699+
### Nested Shape: `ObjectGanttProps.markers[number]`
700+
701+
| Property | Type | Required | Description |
702+
| :--- | :--- | :--- | :--- |
703+
| **date** | `string` | ✅ | Where the line stands — an ISO date (`2026-07-01`, read as that day on the chart's own calendar) or date-time string; a date that does not parse, or falls outside the drawn range, draws no line |
704+
| **label** | `string` | optional | Text drawn against the line |
705+
| **color** | `string` | optional | Line colour, any CSS colour (renderer default: the theme's primary colour) |
706+
699707

700708
---
701709

@@ -1083,7 +1091,7 @@ Sort field and direction pair
10831091
| **mode** | `Enum<'create' \| 'edit'>` | optional | Form mode |
10841092
| **formType** | `Enum<'simple' \| 'tabbed'>` | optional | Parent form presentation — the two variants the renderer honours for the parent half |
10851093
| **sections** | `any[]` | optional | Parent form sections |
1086-
| **fields** | `any[]` | optional | Parent fields shown |
1094+
| **fields** | `string[]` | optional | Parent field names to draw, in order — bare names, as on `object-form`; a `{ name }` or `{ field }` object entry is refused |
10871095
| **details** | `{ childObject: string; relationshipField?: string; columns?: object[]; formFields?: string[]; … }[]` | optional | Detail collections — each a strict entry (`{ childObject, title?, addLabel?, columns?, relationshipField?, … }`) whose `columns` are the inline grid columns a relationship field's `inlineColumns` takes; the FK and columns auto-derive from child metadata when omitted |
10881096
| **title** | `string \| Record<string, string>` | optional | Form title |
10891097
| **submitText** | `string \| Record<string, string>` | optional | Submit button label |
@@ -1203,7 +1211,7 @@ View filter rule
12031211
| **minDate** | `string` | optional | Pin the gantt axis start (ISO `yyyy-mm-dd`) instead of deriving it from the rows; only a non-empty value is honoured |
12041212
| **maxDate** | `string` | optional | Pin the gantt axis end (ISO `yyyy-mm-dd`) instead of deriving it from the rows; only a non-empty value is honoured |
12051213
| **descriptionField** | `string` | optional | Field rendered as each entry's description (renderer default `description`). Declared FLAT because the `timeline` block has no member for it — it is the only spelling this binding has |
1206-
| **mapping** | `any` | optional | Record-to-entry field mapping (`{ title, date, description, variant }`) — the objectui-side binding record read BETWEEN the `timeline` block and the flat fallbacks. Its `variant` member (the field whose value picks each marker colour, renderer default `variant`) is the only spelling that binding has |
1214+
| **mapping** | `{ title?: string; date?: string; description?: string; variant?: string }` | optional | Record-to-entry field mapping `{ title?, date?, description?, variant? }`, each a field name — the binding record read BETWEEN the `timeline` block and the flat fallbacks. Its `variant` member (the field whose value picks each marker colour, renderer default `variant`) is the only spelling that binding has |
12071215
| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Entry-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`) |
12081216

12091217
### Nested Shape: `ObjectTimelineProps.timeline`
@@ -1236,6 +1244,15 @@ Sort field and direction pair
12361244
| **field** | `string` | ✅ | Field name to sort by |
12371245
| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction |
12381246

1247+
### Nested Shape: `ObjectTimelineProps.mapping`
1248+
1249+
| Property | Type | Required | Description |
1250+
| :--- | :--- | :--- | :--- |
1251+
| **title** | `string` | optional | Field whose value is each entry's title — read after `timeline.titleField`, ahead of the flat `titleField` (renderer default `name`) |
1252+
| **date** | `string` | optional | Field whose value is each entry's date — read after `timeline.startDateField` / `timeline.dateField`, ahead of the flat spellings |
1253+
| **description** | `string` | optional | Field whose value is each entry's description — read ahead of `descriptionField` (renderer default `description`) |
1254+
| **variant** | `string` | optional | Field whose value picks each entry's marker colour (renderer default `variant`) — the only spelling this binding has |
1255+
12391256
### Nested Shape: `ObjectTimelineProps.navigation`
12401257

12411258
| Property | Type | Required | Description |

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `ui/` | 196 | 185 | 4 | 0 | 7 |
24+
| `ui/` | 198 | 187 | 4 | 0 | 7 |
2525

2626
## `ui/` — sites
2727

@@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
3636
| `app.zod.ts` | 19 |
3737
| `bulk-action.zod.ts` | 4 |
3838
| `chart.zod.ts` | 8 |
39-
| `component.zod.ts` | 66 |
39+
| `component.zod.ts` | 68 |
4040
| `dashboard.zod.ts` | 11 |
4141
| `dataset.zod.ts` | 4 |
4242
| `i18n.zod.ts` | 1 |
@@ -46,23 +46,23 @@ classify and is not listed (it becomes reportable the day it grows its first sit
4646
| `sharing.zod.ts` | 1 |
4747
| `view.zod.ts` | 60 |
4848
| `widget.zod.ts` | 1 |
49-
| **total** | **196** |
49+
| **total** | **198** |
5050

5151
## `ui/` — open
5252

5353
Per file, how many of its sites still silently discard unknown keys. The `Class`
5454
column that decides the bucket split is hand-written in the ledger; the arithmetic
5555
over it is here.
5656

57-
**7 strip of 196**, in 4 file(s).
57+
**7 strip of 198**, in 4 file(s).
5858

5959
| File | Strip | Sites |
6060
|---|---|---|
6161
| `action-params.zod.ts` | 1 | 1 |
6262
| `app.zod.ts` | 1 | 19 |
6363
| `view.zod.ts` | 4 | 60 |
6464
| `widget.zod.ts` | 1 | 1 |
65-
| **total** | **7** | **196** |
65+
| **total** | **7** | **198** |
6666

6767
| Bucket | Sites |
6868
|---|---|
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #21464 — the top-level `fields` of the `object-form` and
6+
// `object-master-detail-form` page blocks was `z.array(z.unknown())`, held
7+
// while the form drew a `{ name }` field entry its own page-builder guide
8+
// taught. objectstack-ai/objectui#11550 retired that entry from every authoring
9+
// face (the form still draws a STORED one, by its name, as tolerance), so both
10+
// rows now take field names — objectui's own declaration of the member — and
11+
// an object entry is refused with what to write instead. D3 only: page-
12+
// component `properties` is not parsed on the metadata save or load path, so a
13+
// stored page is never refused; and the authored census found no authored
14+
// value to respell — the refused values are fixtures probing the stored read,
15+
// the console warning and objectui's own refusal.
16+
export const entry: SemanticMigration = {
17+
id: 'ui-object-form-fields-names-typed',
18+
surface: 'page `object-form` and `object-master-detail-form` components — `properties.fields` (whose '
19+
+ 'entries used to accept any value)',
20+
replacement: 'a list of bare field names, in the order the form draws them. Write a `{ name: \'email\' }` '
21+
+ 'entry as `\'email\'` — the form only ever drew its name — and move a `label` or `required` override '
22+
+ 'onto a `sections[].fields` entry (`type` is always the object field\'s); write a `{ field: \'email\' }` '
23+
+ 'entry as `\'email\'`, or move it into a section\'s `fields`, the vocabulary it belongs to.',
24+
reason: 'The form reads its top-level `fields` as the names of the fields to draw, in order, selecting '
25+
+ 'from the object\'s fields and from `customFields`; the master-detail form hands its own to the parent '
26+
+ 'form verbatim. objectui declares the member `string[]`, but the page-component rows declared it '
27+
+ '`z.array(z.unknown())` while the form drew a `{ name }` entry by that name — the shape objectui\'s '
28+
+ 'page-builder guide taught, with a `label`, `type` and `required` the form silently dropped. '
29+
+ 'objectui has since retired that entry from every authoring face — the guide and its fixtures name the '
30+
+ 'fields — keeping only a STORED one readable; so both rows now take field names, and refuse an object entry with what to write instead: a '
31+
+ '`{ name }` entry is its bare name, and a `{ field }` entry — the `sections[].fields` vocabulary, which '
32+
+ 'the form skips at the top level with a console warning — is its bare name or belongs in a section. It is '
33+
+ 'read where every page component\'s props are: the component-props gate reports a refused value as an '
34+
+ 'advisory `component-props-invalid` finding on `objectstack validate`, `objectstack build` and '
35+
+ '`objectstack lint`, and a stored page still saves and loads, because a page component\'s `properties` is '
36+
+ 'not parsed on the metadata save or load path. No conversion is registered: nothing on the load path '
37+
+ 'refuses the shape, the form already draws a stored `{ name }` entry by its name, and an override written '
38+
+ 'beside it has no rewrite that keeps it — moving it onto a section is the judgment this entry leaves to '
39+
+ 'the upgrader. Deployed metadata NOT MEASURED.',
40+
acceptanceCriteria: 'Every `object-form` and `object-master-detail-form` node validates: `objectstack '
41+
+ 'validate` reports no `component-props-invalid` finding under `properties.fields`. Each form draws the '
42+
+ 'fields its list names, in that order, with any per-form label or required override taken from its '
43+
+ 'section entry.',
44+
};

0 commit comments

Comments
 (0)