Repository navigation
Commit 4331a6b
Fixes #21464
Clause-②: yes (narrowing)
## What this does
The S-final stage of the `ComponentPropsMap` `z.unknown()` close-out,
and its last. It executes the maintainer's rulings on forks 1, 4 and 5
of the decision card #21704: fork 1 letter B (ruling record
`5978663135`, batch #276), forks 4 and 5 letters B and A (record
`5979239990`, batch #277), per the claim `5981629450`. Read points are
at the `.objectui-sha` pin `2e818d0b51ec`, under objectui `packages/`
unless named. Every cited reader file is byte-identical at objectui
`main` `2abec3a96` (the pin is an ancestor of it;
`plugin-timeline/src/renderHandoff.ts` and `types/src/data-display.ts`
differ there in comments and a typed click slot only, not in either
arm).
| row · member | was | now |
|:--|:--|:--|
| `object-metric` · `drillDown.report` | `z.unknown()` | `ReportSchema`,
by reference |
| `object-timeline` · `items` | `z.array(z.unknown())` | a closed entry
(module-private, built once) of objectui#6356's two arms, paired with
the row's `variant` by a row refinement |
| `action:group` · `actions[]` | `z.record(z.string(), z.unknown())` per
member | a closed inline action (module-private): `action:button`'s keys
by `type`, plus the inline button's `size` |
| `action:menu` · `actions[]` | the same | the same member without
`size` |
With these three typed, the enumeration pin's `fork` lines are gone, the
`fork` stage is gone with them, and a new §6 pins the close-out: no
stage is declared and no ledger line is `staged`.
## Fork 1 B — `drillDown.report` is `ReportSchema`
- **The read.** The tile hands `report` to the shared drawer verbatim
(`plugin-dashboard/src/ObjectMetricWidget.tsx:742`).
`DrillDownDrawer.tsx` draws it as a `report` node when
`isDatasetBoundReport` holds (`:92`, used at `:115`) — a non-empty
`dataset`, or a `joined` report with a block that binds one — joining
the metric's filter into the report's own `runtimeFilter` (`:150-153`).
Any other value lists the records. objectui types the member as this
package's `ReportSchema` author input (`SpecReportInput`). Both files
are byte-identical from `ab1879721595`, where the fork was measured.
- **The premise, re-measured: admitted implies drawn.** Since #21702
(`ed15448217`) the joined arm refuses every block with no `dataset`, and
every other type needs `dataset` and `values`. The new pin's §4 restates
`isDatasetBoundReport` from the pin and checks it over 14 candidate
reports: every report the member admits is drawn (zero exceptions), and
the four writer shapes are admitted (a lit control).
- **The remaining difference runs one way only, and is pinned as such.**
The drawer also draws four incomplete reports the member refuses: a
joined report with only some blocks bound, a joined report with a
container `dataset`, a report with no `name` / `label`, and a summary
report with no `values`. No measured writer authors any of them.
- **`drillDown`'s other members, stage 5's.** `enabled`, `title`,
`target`, `columns` and `maxRows` stay the chart drill-down's by
reference, and `filter` / `mode` stay refused by name. The block's
describe and docblocks now say `report` is `ReportSchema`. The metric
family pin's §3 key set is unchanged.
- **Parsed type.** `ReportSchema`'s defaults (`type`, `drilldown`)
materialize on parse, so `ObjectMetricPropsParsed` now also differs from
the authored type on `drillDown.report`. Its docblock says so. A page
component's `properties` is not parsed on the way to the renderer, so
the drawer still reads the report as written.
## Fork 4 B — the timeline entry, both arms closed
- **The arms, read at the pin.** The feed branches read `time`, `title`,
`description`, `variant`, `icon`, `content` and `className`
(`plugin-timeline/src/renderer.tsx:1612-1659` vertical, `:1697-1716`
horizontal). The gantt branch reads a row's `label` (`:1899-1900`) and
`items` (`classifyGanttRows`, `:617-621`; drawn at `:1908`), and each
bar's `startDate`, `endDate` (`:1909`), `variant` (`:1916`) and `title`
(`:1918`, `:1921`). That is exactly objectui's `TimelineFeedItem` (seven
keys) and `TimelineGanttItem` / `TimelineGanttItemBar`, taken as ruled.
- **One entry shape, not a union.** The entry declares every member of
both arms, each optional, closed against anything else — objectui's own
`TimelineItemSchema` shape. A union would fold an off-shape bar's issue
into one `invalid_union` at the entry, as objectui's docblock records.
- **The row refinement** (`objectTimelineItemsFitVariant`, restating
objectui's `timelineItemsFitVariant`) pairs each entry with the arm the
row's `variant` selects (absent means `vertical`). It refuses, each at
the key it names: the arm's required key absent (`title` on a feed
entry, `label` on a gantt row), or a key only the other arm declares.
The arm key lists are read off the two shapes, never restated.
- **`content` is opaque**, by the ruling: `z.unknown()`, its describe
says "Held opaque", and the enumeration pin records it under a new
`opaque` reason naming the ruling record (§2 checks both). It is not a
slot position.
- **Gantt-bar dates** are a string or a finite number (`z.number()`
refuses `Infinity` and `NaN`). The `Date` arm is left out, by the
ruling.
- **The record-composed keys** (`color`, `startDate`, `endDate`,
`group`, `meta` on an entry) are refused, each with what an authored
entry writes instead. `date` is an alias for `time`.
## Fork 5 A — the container members, measured from the reads
- **The read set, measured** from `action-group.tsx`
(`InlineActionButton` `:91-174`, `DropdownActionItem` `:188-247`,
`handleExecute` `:306-385`), `action-menu.tsx` (`ActionMenuItem`
`:92-150`, `ActionAutoTrigger` `:177-191`, `handleExecute` `:244-334`),
`static-params.ts` (`:142-148`, `:172-183`) and `auto-trigger.ts`
(`:96`). It is not transcribed from `UIActionSchema`. All four files are
byte-identical from `ab1879721595` and at objectui `main`.
- Drawn: `label` (or `name`), `icon`, `variant` and `tags`
(`separator-before`, the one tag read, `:224` / `:408`).
- Gated: `visible` and `disabled`.
- Placed: `locations` (`actionRendersAt` on the group, `:304`).
- Forwarded to the runner: `type`, `name`, `label`, `description`,
`target`, `openIn`, `method`, `params`, `bodyExtra`, `bodyShape`,
`operation`, `patch`, `confirmText`, `successMessage`, `errorMessage`,
`refreshAfter`, `locations`, `toast`, `resultDialog`, `onSuccess` and
`objectName`.
- **That is `action:button`'s keys by `type`**, with two differences the
reads decide, and §3 derives the key set from
`ActionButtonPropsSchema.shape` to pin them. `undoable` and
`recordIdField` are not forwarded by either container. `tags` is drawn
by both. `size` is read only by a group's inline button (`:124`, `md`
drawn as `default`); an `action:menu` item reads none and declares none
(the `action:icon` precedent).
- **Value schemas are the rows'**, key by key. `visible` / `disabled`
take the rows' own `actionCondition()`, and §3 checks the same accept
set and the same envelope. The runner-forwarded blocks stay
`z.unknown()` with "forwarded to the runner" in their describes, so the
enumeration pin's `runner` reason holds for each.
- **Refused, each with a prescription:**
- `actionType` → `type` (the rows' alias table, turned round);
- `endpoint` / `url` / `path` / `href` → `target` (the rows'
`ACTION_TARGET_ALIASES`);
- `enabled` (the rows' own text) and `autoTrigger` (the rows' text on
`action:menu`; on `action:group`, which never reads it, the text says
so);
- `outcomeMessages` → `successMessage`;
- a member `className` → `variant`, or the node's own `className`;
- `properties` → `bodyExtra`, or the action as its own `action:button`;
- `undoable` / `recordIdField`;
- an `action:menu` member's `size`.
- **`outcomeMessages` stays undeclared on all four action blocks.** §3
pins that none of `action:button`, `action:icon`, `action:group`,
`action:menu` and neither member shape declares it. The `action:button`
/ `action:icon` rows are not edited.
## The census (writers of all three members)
**Instrument.** This run's TypeScript-AST walk over code and fenced
docs, with same-file constants and spreads, `.map` over a constant list,
templates and same-file helper calls evaluated. It reads:
- object literals naming the block (the `type` also through a spread
constant);
- typed literals;
- direct parses through the row (receiver constants resolved);
- the block's JSX component (`schema={…}`, or `ObjectMetricWidget`'s own
props);
- helper parameters at every same-file call site, for both member and
whole-node positions;
- a loose pass over every `report` / `items` / `actions` key in a file
naming a block.
Every static value was parsed through this branch's rows; each value
with a non-static part, and each refusal, was read by hand.
**Cross-check:** it reproduces stage 5's `drillDown` population exactly
(26 values) and the S-objectui-held census's 40 `action:group` / 19
`action:menu` values.
- **objectstack** at `1289925c0a` (the base; `main` moved 5 commits to
`33f97917ac`, merged here through `os-regen-merge.sh`, none touching a
census corpus file):
- one drill report (the metric pin's own), which parses;
- one timeline `items`
(`component-element-navigation-17987.test.ts:213`, a feed entry), which
parses;
- three container members
(`component-action-element-rows-20371.test.ts`), which parse, plus that
file's two probes the old row already refused;
- no example, doc or skill writes any of the three.
- **objectui** at the pin `2e818d0b51ec` and at `main` `2abec3a96`, the
same counts at both:
- **`drillDown.report`:** of the 26 `drillDown` values, 2 carry a
`report`. The drawn one
(`objectMetricDrillDownMembers-8071.test.tsx:281`) parses. The other is
that file's `it.each` pair (`:305`), the two values the drawer does NOT
draw, both refused. The loose pass's 34 `report` keys: 17 parse
(drill-mirror tests and the dashboard guide); 9 are refused, all refusal
probes on objectui's own faces (`{ name }`, `{ name: 42 }`, the matrix
with no `values` in `drill-down-report-name-retired-11517.test.ts`); 4
are not static and 4 are not report objects (i18n strings).
- **`items`:** 18 values. 15 parse: feed entries, gantt rows and the
empty gantt. The 3 refused are the render-time gantt date diagnostic's
own probes, an array, `false` and `null` bar date
(`timeline-gantt-date-spelling-6907.test.tsx:338`,
`timeline-gantt-date-type-rule-6781.test.tsx:419`,
`timeline-gantt-null-date-6770.test.tsx:220`).
- **Members:** `action:group` 40 values (26 parse, 2 refused, 12 partly
non-static) and `action:menu` 19 (11 parse, 3 refused, 5 partly
non-static), all in tests but one run-time hand-off. Every refused
member is a probe of a read the ruling refuses:
- the member pin's `className`
(`action-group-menu-inputs-11168.test.tsx:249`);
- the `outcomeMessages` forward tests
(`action-outcomeMessages-forward-11344.test.tsx:147`, `:158`);
- the `properties.params` static-value tests
(`action-container-member-params-10290.test.tsx`, read by hand);
- the host's `autoTrigger` flag
(`action-overflow-autotrigger.test.tsx:298`, and as a test device in
`action-onSuccess-forward.test.tsx:182`,
`action-objectName-onClick-4202.test.tsx:127` and
`action-menu-host-disabled-11182.test.tsx`).
The partly non-static members are predicate variables, helper-built
members and code-composed `onClick` functions, read by hand; their
static keys parse.
- **The run-time hand-off** is `action:bar`'s overflow menu
(`action-bar.tsx:287`). It hands the bar's own members — the registered
actions a host passes — to `action:menu` at run time, never through the
component-props gate, and those members are `ActionSchema` entries (the
ruled-out option C). Recorded, not a block writer.
- **hotcrm** at `4054ec2680` and **cloud** at `2205b53010`: no writer of
any of the three. hotcrm's four `object-metric` tiles declare no
`drillDown`.
- **No measured working writer is refused**, so the stop valve does not
trip on any of the three members.
## Release
-
`.changeset/21464-component-props-report-items-action-members-typed.md`:
`'@objectstack/spec': minor`, the BREAKING banner, `Clause-②: yes
(narrowing)`, the ADR-0087 `registered` marker naming the three ids, a
FROM → TO table and the census.
- Three D3 entries in step 18:
`18.ui-object-metric-drill-down-report-typed.ts`,
`18.ui-object-timeline-items-typed.ts` and
`18.ui-action-group-menu-members-typed.ts`. The semantic region was
regenerated by `gen:migration-registry`.
- Three rationale fragments at orders 80, 81 and 82, the next free after
S-forms' 79.
- No D2 conversion and no `RETIRED_KEYS_BY_MAJOR` row: page-component
`properties` is not on the save or load path, and no declared key is
removed.
- No export is added or removed: the entry, bar and member builders are
module-private.
- Generated: `content/docs/references/ui/component.mdx` (two member
tables and the timeline entry table) and the strictness counts (`ui/`
204 → 208, `component.zod.ts` 74 → 78: the entry, the bar and the two
members).
- `dropped-refinements.baseline.json` 670 → 676. `ObjectMetricProps`
gains the five `ReportSchema` refinement sites carried by reference, the
S-forms growth-by-reuse precedent. `ObjectTimelineProps` gains its root,
the new pairing refinement.
- Dogfood expression-conformance ledger: two rows,
`cel-action-member-visible` (fail-closed) and
`cel-action-member-disabled` (fail-closed), for the two new positions
`actionContainerMemberShape.visible` / `.disabled`. Each has one
evaluation leg, the container's, and no node gate (#20420 and the
S-forms rows are the precedent).
## Tests
- **Red first,** through the published `@objectstack/spec@17.6.0` (npm
tarball, `ComponentPropsMap[type].safeParse`): 33 of the 34 values this
branch refuses are ACCEPTED there. The one refused, `items: 42`, was
already refused by the old `z.array` (a control). On this branch all 34
are refused.
- **The new pin
`component-report-items-action-members-typed.pin.test.ts`:**
- §1: the writer shapes parse — timeline entries and members
byte-identical, drill reports to exactly `ReportSchema.parse(report)`;
- §2: 34 refusals by `code` and `path` (the red-first set), plus the
prescriptions;
- §3: the vocabulary pins (the report def identity, the arms' key sets,
the member key set derived from the button's, `outcomeMessages` absent
on all six faces, the shared condition);
- §4: admitted implies drawn;
- §5: the D3 ids.
- **The enumeration pin:** 23 new reasoned lines (the member predicates'
`ast`, the runner-forwarded members, the report's two `runtimeFilter`
positions, the opaque `content`), the four fork lines gone, and §2 and
§6 added. **The metric family pin:** the drawn report row moves out of
the byte-identical table into its own case, asserting the parse equals
`ReportSchema`'s answer (no `expect` removed, no skip).
- `pnpm --filter @objectstack/spec test` at `b7335d8374`: Test Files 615
passed (615); Tests 18358 passed, 1 todo. `pnpm --filter
@objectstack/spec typecheck` at the same head: exit 0, test layer held
(52 files / 246 errors / 135 signatures; the touched pins are on
`tsconfig.test.json`). `pnpm --filter @objectstack/lint test`: 119
files, 5627 tests passed. The showcase `validate`: passed. Dogfood
`test/expression-conformance.test.ts`: 7 passed.
- **Ablation**, one leg per member (two for the timeline), each through
`scripts/ablation-replace.mjs` in a driver with an EXIT / INT / TERM
trap, restored and proven by blob hash (HEAD blob `b4dcaf34d2`, `git
diff HEAD` empty after every leg):
- `report` → `z.unknown()`: 17 red;
- `items` element → `z.unknown()`: 18 red;
- the pairing refinement dropped: 5 red;
- the `action:group` member → an open record: 15 red;
- the `action:menu` member → an open record: 13 red.
- **Public door,** through built `lint`'s `validateComponentProps`.
Nothing is reported for the drawn report drill, feed entries or group
members. `component-props-invalid` is reported at
`drillDown.report.dataset` / `drillDown.report` and at `items.0.title` /
`.label` / `.items`. `component-props-unknown-key` is reported for an
entry's `color`, a member's `actionType` and a menu member's
`outcomeMessages`.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` gives 114 commands (merge base
`33f97917a`, 14 paths, 1705 changed lines). All 114 exit 0 at
`b7335d8374`, and the `--ran` reconciliation reads 114 derived, 114 run,
0 NOT-MEASURED, 0 UNRUN. Two first ran as PREREQUISITE NOT MET
(`check:skill-examples`, `check:dual-build-cjs-loads`) and were re-run
green after `turbo run build --filter=!@objectstack/docs` (72
successful).
- **The changeset gates with this body as the `pull_request` payload**
(`--event`): `check-changeset-no-major` (LEVEL AXIS: `yes (narrowing)`,
no moved package graded `patch`), `check-adr-0087-registration` (one
declared-breaking changeset, `registered` with the three ids) and
`check-empty-changeset`, each exit 0.
- **eslint, a proven narrowing of `pnpm lint`:** `eslint
--no-inline-config --format json` over the 10 changed `.ts` files reads
10 files, 0 errors, 0 warnings. The population is the repo's one
`eslint.config.mjs`, which ignored none of the 10. The narrowing
excludes nothing: that config never enables type-aware linting
(`eslint.config.mjs:327-328`, no `parserOptions.project`), so this diff
cannot move a verdict on an untouched file.
- **NOT MEASURED:** the Console Pin Gate, the Dogfood Regression Gate's
full suite and the full `pnpm lint`, reason: CI-owned. objectui was read
at the pin and at `main`, not built against this spec.
## Acceptance notes
Noted, not filed:
- **A member's object `params`** is forwarded as the request payload of
a `type: 'api'` member only. On any other type the container drops it
with a development warning (`static-params.ts:172-183`). The member
keeps `params` as the rows do (`z.unknown()`, runner-forwarded), and its
describe says so.
- **The `action:bar` hand-off** above composes `action:menu` members
from the host's registered actions at run time. Those carry
`ActionSchema` keys (`outcomeMessages`, `order`, `component`, …) the
member shape refuses. No gate judges that composition, and the
`action:button` row's docblock already records the same member path for
`outcomeMessages`.
- **The D2 conversion `action-block-endpoint-to-target`** rewrites a
stored `endpoint` on `action:button` / `action:icon` nodes only. A
stored member `endpoint` is not rewritten. The census found no writer of
one, and the refusal carries the rename.
- **Earlier step-18 rationale fragments**
(`ui-object-metric-drill-down-typed`,
`ui-object-timeline-mapping-typed`) still say these members "stay open".
The new fragments (orders 80-82) follow them and say they are now typed,
so the joined text reads in order. The older fragments are not edited,
the S-forms precedent.
- **The grid widget's camelCase keys** (objectui#11610, landed on
objectui `main` as `2abec3a9`, not yet at the `.objectui-sha` pin) are
not declared on the S-forms runtime form field here. That is fork 2's
follow-up, outside this claim's file surface; its carrier is the next
pin bump.
## Not in this PR
- No `action:button` / `action:icon` row edit, and no `view.zod.ts` /
`report.zod.ts` edit.
- No objectui change and no pin bump.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent b7a13c7 commit 4331a6b
14 files changed
Lines changed: 1528 additions & 177 deletions
File tree
- .changeset
- content/docs/references/ui
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- qa/dogfood/test
- spec
- src
- migrations
- entries/semantic
- ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
0 commit comments