Repository navigation
feat(spec)!: record:line_items gets its ComponentPropsMap row, its columns are the inline grid column contract, and the showcase Tasks grid binds by name (#21142) - #21244
Conversation
…s by `name` The `record:line_items` block on the project detail page wrote its columns keyed `field`, while the line-items grid binds a column by `name`, so every cell rendered empty. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…lumns are the inline grid column contract The type leaves the string-arm registration ledger, which is now empty. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…the lint pins, regenerate artifacts Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…ath form Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…ne-items-columns-name
The os-regen driver kept the branch side of authorable-surface/ui.json and the component reference page; regeneration restores main's `ui/Action:outcomeMessages` beside this branch's record:line_items keys. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 46c31cb17d8f7c4f6c25031b89c9f56e5ee7fa6e && git checkout 46c31cb17d8f7c4f6c25031b89c9f56e5ee7fa6e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4727fcb22a7e6a28da1ad20ef74469fbabb8dd0e 75f98b26a43fa1d485285b05a0eda306bcc13fb9 && git checkout -B drift-repro 4727fcb22a7e6a28da1ad20ef74469fbabb8dd0e && git merge --no-ff 75f98b26a43fa1d485285b05a0eda306bcc13fb9
node scripts/docs-audit/affected-docs.mjs --json 4727fcb22a7e6a28da1ad20ef74469fbabb8dd0e
|
Contract reviewServed-tier: Rendered 2026-10-01T21:37Z by an isolated reviewer. Inputs: card #21142 (body and its three comments: the triage grade ① Derived judgmentsAccept set, before and after. Before: The fifteen keys, re-measured at the pin. My own count of distinct Required keys. The four refused detail-entry keys. Zero reads of
ADR-0087 disposition, measured (its own paragraph). Four pre-PR shapes were judged against the pinned renderer. (a) A Public surface, each move for its stated reason. Vocabulary.
Lint pins and header edits. Producer. Five columns Published prose, sentence by sentence. Changeset: every sentence TRUE, including the gate list (verified at ② Semver level
③ Boundary flagsDev deviations, each answered. (1) File surface beyond the claim's list (three lint files, the carrier test, the new showcase test, the rationale fragment): each is a consequence of the row and none changes behaviour; accepted. (2) Guidance for four detail-entry keys: measured unread at the pin above; accepted. (3)
Flags raised by this review, none verdict-moving. (a) The shared column describes carry hydration wording this carrier contradicts; a Check-runs on the head at the final read (2026-10-01T21:36:59Z, REST Implemented-by: VERDICT: PASS |
…ne-items-columns-name The one textual conflict, dropped-refinements.baseline.json's two measured counts, is resolved to 212 / 616: the reading build-schemas prints for the merged source (taken in a throwaway worktree), which the merged ledger's own 212 entries and 616 sites corroborate. Both sides' entry hunks merged as is. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…e merged tree The os-regen driver kept this branch's side of the five ui.json shards, the strictness-ledger counts and the reference index in merge 21c74a7, dropping main's FormFieldPublicPicker retirement. Regenerated from the merged source after a container restart; build-schemas reads 616 sites across 212 schemas, the counts the merge resolution recorded. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Rendered 2026-10-01T23:48Z by an isolated reviewer, as a fresh record on the merged head: the pure-regeneration carry does not apply, because the hand-maintained ① Derived judgmentsThe delta first. The head is the regeneration commit on top of merge 21c74a7, whose parents are the reviewed head c2b9101 and
Zero hand-written lines differ from the reviewed diff. The regenerated files reflect what
Carried judgments, on the evidence that their hunks are byte-identical. The accept set of ADR-0087 disposition, D3-only. The D3 entry and the changeset are byte-identical to the reviewed ones. The two facts the disposition rests on were re-read at this head: Prose. The changeset is byte-identical and every sentence re-read on this head stays TRUE (the fifteen-key list, the advisory gate list, the save-and-load sentence, the FROM-to-TO table, the census of one authored block, "Deployed metadata was not measured"). The PR body is unchanged; its "Tests (at HEAD c2b9101)" section is a statement about that head and remains true as one, and the head's own readings are in the base-merge report ② Semver level
③ Boundary flagsBase-merge round report Flags of the earlier record Flags raised by this review: none. One observation for the landing seat only: the PR body's test table names the earlier head, which is accurate as written; the head's readings live on the card. Check-runs on the head at the final read (2026-10-01T23:44Z, REST Implemented-by: VERDICT: PASS |
…a column reference (objectstack-ai#21220) (objectstack-ai#21240) Fixes objectstack-ai#21220 Clause-②: yes Dispatched by the PM claim `5938507454` (PM loop round 1, `domain:spec` seat 1), on the triage direction `5938409101`. An ADR-0021 dataset's `dimensions[].field` and `measures[].field` now take the column-reference accept set the cube members they compile to already hold since objectstack-ai#20943 (PR objectstack-ai#20998), from ONE shared declaration. A non-column value is refused at parse, at `dimensions.N.field` / `measures.N.field`, with a prescription naming the ADR-0021 form. The runtime door from PR objectstack-ai#21190 is untouched and stays as defence in depth. The changeset carries the `(narrowing)` arm, the BREAKING banner at `minor`, and the ADR-0087 marker `registered dataset-member-field-expression-refused, dataset-count-measure-empty-field-removed`. ## Patch round 2 — contract review `5940617829`, item ①.6 The review found one lossless sub-shape that the first round sent to D3 only: a dataset measure `{ aggregate: 'count', field: '' }`. It parses on the base, and the objectstack-ai#21190 door skips it (its `!== ''` guard). On SQLite's native path it compiled to `COUNT()` and answered 200. Its producer is Studio's dataset inspector. This round: - **New D2 conversion `dataset-count-measure-empty-field-removed`** in `MAJOR_18_CONVERSIONS` (`order: 54`, inserted at its identifier's sort position, defined directly above `elementFilterRemoved`). `main` landed `form-field-public-picker-removed` at 53, so this entry takes the next free number. - It drops `field` from a `count` measure whose `field` is exactly `''`. Without the key, `compileDataset` emits `sql: m.field ?? '*'`, which is `COUNT(*)`: the row count. - Its mechanics are measured from the precedent `time-default-utc-suffix-dropped`, not recalled: `toMajor: 18`, `retiredFromLoadPath: true` (ADR-0087's ratified pre-GA policy for a lossless repair), and `retiredAfter: '17.5.0'` (the spec's current version, the same value the precedent carries). - It uses the shared `stripKeys` helper and emits one notice per removed key. - Its fixture is disjoint. The controls are left as stored: a dimension `''`, a `sum` over `''`, a count over `'*'`, a count with no `field`, and a count over a column. - **Scope: only `count` + `''`.** A non-count measure with `''`, a dimension with `''` and every expression have no working row or no mechanical rewrite, so they stay D3-only. A padded value has no known producer and is out of scope. - **The D3 entry links the conversion** with `conversionIds: ['dataset-count-measure-empty-field-removed']`, the way `18.time-default-zone-refused.ts` links its conversion. Its `reason` now says what is true: the door refuses an expression, and it never judged `''`. It also says that D2 carries the `count` + `''` repair and D3 the rest. `acceptanceCriteria`, the `STEP18_RATIONALE` fragment, the changeset, the two ledger notes and the `dataset.zod.ts` comment are corrected to match. `registry.ts` was regenerated by `gen:migration-registry`. - **Pins.** The new `src/conversions/dataset-count-measure-empty-field-removed.test.ts` covers four things on a STORED row, through `applyConversionsToStoredItem('dataset', row)`: - The key is dropped, with one notice per measure, and the row then parses. - Every control is the same reference. - The replay is idempotent. - The conversion is registered under major 18, retired from the load path, and linked from the D3 entry. The table-wide fixture replay in `conversions.test.ts` covers before → after. - **What a NEW save does.** The write path parses with the current schema and replays no conversion, so a new Studio save of `field: ''` is still refused at save with the prescription to omit the key. The producer-side change stays objectui's. A row already stored that way is repaired on load at every stored-row seam. ## What changes **`@objectstack/spec`** - **One pattern.** The new module `packages/spec/src/data/analytics-column-reference.ts` declares the column path once (a bare identifier, then zero or more `.identifier` hops). It sits outside the `data` barrel, like `ui/analytics-carrier-filter.ts`, so it is not published API. It exports two anchored forms built from that one source string: - `ANALYTICS_COLUMN_REFERENCE`: the path, or `'*'`. - `ANALYTICS_COLUMN_PATH`: the same path without the `'*'` arm. - **Cube layer unchanged.** In `data/analytics.zod.ts`, `CUBE_MEMBER_SQL` is now that same `RegExp` object: `const CUBE_MEMBER_SQL = ANALYTICS_COLUMN_REFERENCE`. The declaration stays in this file on purpose. ADR-0021's 2026-10-01 note links to `analytics.zod.ts#CUBE_MEMBER_SQL`, and ADRs are a governed surface this PR does not edit. The cube members' JSON-Schema `pattern` is byte-identical; the new pin asserts it. - **Dataset layer.** In `ui/dataset.zod.ts`: - `DatasetMeasureSchema.field` uses `.regex(ANALYTICS_COLUMN_REFERENCE)`. Admitted: a column, a relationship path, or `'*'`. A count may still omit `field`. - `DatasetDimensionSchema.field` uses `.regex(ANALYTICS_COLUMN_PATH)`, so a dimension also refuses `'*'` (see measurement 3). - Both are `.regex()`, not refinements, so the published JSON Schema carries each as a `pattern`. `dropped-refinements.baseline.json` is untouched. - The refusal code is `invalid_format`. Each prescription opens with the contract sentence and names ADR-0021. - The measure prescription names the measure `filter` form and `derived: { op, of: [...] }`, with the 0–1 ratio scale. - The dimension prescription says a CASE bucket becomes a field of the object. - The two `describe()` texts now say "never a SQL expression". `content/docs/references/ui/dataset.mdx` is regenerated from them. - **ADR-0087.** - New D3 entry `migrations/entries/semantic/18.dataset-member-field-expression-refused.ts`. `registry.ts` was regenerated by `gen:migration-registry` and never hand-edited inside the markers. - One hand-written `STEP18_RATIONALE` fragment, inserted at the id's sort position with `order: 58`. Round 1 used 57. After the round-2 base merge it takes 58, because 57 is allocated to the in-flight PR objectstack-ai#21244's fragment. Neither list requires unique orders: `step18-rationale-merge.test.ts` models two fragments sharing one `order` and only asserts a positive integer, and `main` already holds two 56s. So whichever of the two PRs lands first, neither re-orders. - One D2 conversion, `dataset-count-measure-empty-field-removed`, for the one lossless sub-shape (a `count` measure's `field: ''`; see Patch round 2). The D3 entry carries the rest: an expression has no mechanical rewrite into a column. - No `RETIRED_KEYS_BY_MAJOR` row: no key left the shape. - **Liveness.** The `dataset` ledger rows `dimensions.field` and `measures.field` stay `live`. Each is re-verified on 2026-10-01, with the narrowing recorded in its `note`. - **Guide.** `content/docs/data-modeling/analytics.mdx` gains one "Key rules" bullet saying that `field` is a column reference. **Ratchets, as expected for a value narrowing.** The `api-surface`, `authorable-surface`, `json-schema.manifest`, `export-origins` and `declaration-map` artifacts are byte-identical. `spec-changes.json` and the upgrade guide stay at protocol 17, so major-18 entries do not project yet, and both checks are green. ## The PM's mechanism assumptions, measured 1. **Confirmed.** `dataset.zod.ts:125` (dimension, required) and `:189` (measure, optional) were bare `z.string()` at the base `d6d6e872`. `CUBE_MEMBER_SQL` was a module-private `const` at `analytics.zod.ts:240`. 2. **Exporting `CUBE_MEMBER_SQL` would move the public surface.** `packages/spec/src/data/index.ts` re-exports the whole module with `export * from './analytics.zod'`, so an exported `CUBE_MEMBER_SQL` becomes a new `@objectstack/spec/data` export and needs `gen:api-surface`. I took the non-public module instead. `check:api-surface`, `check:export-origins` and `check:declaration-map` are green with zero changes to their artifacts. 3. **`'*'` on a dimension: measured, and refused.** Readings come from `POST /api/v1/analytics/dataset/query` with today's spec, through the real REST route, a real `AnalyticsService` and a real better-sqlite3 `SqlDriver`, using a temporary probe test that was deleted afterwards (the tree is clean). The ObjectQL-strategy column bridges `executeAggregate` straight to `SqlDriver.aggregate`, not through the ObjectQL engine. | dataset member `field` | native-SQL strategy | ObjectQL strategy | |---|---|---| | dimension `'*'` | 500 `DATABASE_ERROR` (`SELECT * AS ... GROUP BY *`) | 500 `DATABASE_ERROR` (`groupBy: ['*']`) | | dimension `''` | 500 | 500 | | count measure `''` | 200 (SQLite accepts the `COUNT()` it compiled to) | 500 | | count measure `'*'` (control) | 200 | 200 | | sum measure `'*'` | 500 (`SUM(*)`) | 500 | | padded `' amount'` (sum) | 200 | 200 | | padded dimension `' industry'` | 200 | 200, **dimension column silently missing from the rows** | | expression `amount * 2` | 403 `PERMISSION_DENIED` (PR objectstack-ai#21190's door) | 403 | A `'*'` dimension is never answered: it compiles to grouping by every column, which is not an axis. So the dataset dimension takes the same path pattern without the `'*'` arm. That is one pattern source with one stated restriction, not a second pattern; the pin proves the dimension's published `pattern` equals the cube's with only the `\*|` arm removed. ⚠ **Flagged, not silently chosen.** The triage line reads "exactly the `CUBE_MEMBER_SQL` accept set" for both keys. This PR narrows the dimension one step further, as the dispatch's mechanism item 3 invited and the card's own pin wording ("`*` (on a measure)") suggests. The cube `DimensionSchema.sql` still admits `'*'`, per ruling D's execution parameters, and is untouched here. 4. **Census, repo-wide, with a lit control.** A scan of every `field:` value in tracked files that mention a dataset and `dimensions`/`measures`, including `packages/**` tests, `content/docs/**` and `skills/**`. - **Lit control.** Column-reference values hit in every area, and the scanner sees them: `examples` 116, `content` 88, `skills` 15, `platform-objects` 6, `service-analytics` 587, `spec` 423, `lint` 282, `rest` 92. - **Non-column dataset `field`s found:** only the fixtures that PR objectstack-ai#21190 wrote on purpose to drive its door: `rest` `analytics-16019-driver-declared-fault.test.ts` (`translate(...)`, `lower(name)`) and `service-analytics` `inline-dataset-field-admission-door.test.ts` (an expression constant and a template). Both were re-pinned (next section). - **Zero** in the examples, `platform-objects`, the hand-written docs and the published skills. Every other non-column literal the scan caught is not a dataset field (driver-sql and protocol prose, filter paths, `$field` prose in a skill). - **The build as judge.** The full suites of `spec`, `lint`, `service-analytics`, `metadata-protocol` and `rest` are green on the narrowed contract. - Nothing in `skills/**` teaches an expression `field`, so no Tier H follow-up is owed. 5. **D2 for one sub-shape, D3 for the rest** (corrected in round 2; the first round said "D3 only"). - **Expressions: D3 only.** A stored dataset with an expression `field` already answered 403 at the dataset door. On the REST route it is now refused one step earlier, at the route's own `DatasetSchema` parse, which the route runs on the inline and the saved branch alike. An expression has no mechanical rewrite. - **Correction.** This item said that no stored row worked before. That is false by this PR's own probe table: a `count` measure with `field: ''` answered 200 on SQLite's native path, and the door never judged it. - **The repair.** That sub-shape gets the D2 conversion `dataset-count-measure-empty-field-removed`, which every stored-row rehydration seam replays (`applyConversionsToStoredItem`, e.g. `metadata-protocol`'s `convertStoredItemDetailed`). The runtime door is still reachable for a dataset handed to `queryDataset` unparsed: the build probe's dashboard-widget path (`metadata-protocol` `build-probes.ts`) passes the stored row as read. ## Fixture triage (two consumer tests the narrowing turns red; both re-pinned, not loosened) - **`service-analytics` `inline-dataset-field-admission-door.test.ts`** built its expression fixtures with `DatasetSchema.parse`, which now refuses them. The fixtures are now built UNPARSED, the shape a pre-narrowing stored row has, through `storedDatasetWith`. The controls still parse. One new case asserts that the contract refuses both fixtures at `dimensions.0.field` / `measures.0.field`. All 4 provider tiers x 2 strategies of the 403 door pins are unchanged and green. - **`rest` `analytics-16019-driver-declared-fault.test.ts`.** The route parses every dataset first, so its inline and saved expression cases now answer `400 VALIDATION_FAILED`, where they answered `403 PERMISSION_DENIED`. - Both cases are re-pinned to the `400`, plus `invalid_format` at the path inside `detail`, the driver never called, and no expression text echoed. - The statement-leak check now reads SQL keywords as the strategies emit them (upper case), because the prescription itself says "Group by the column itself" in prose. It was case-insensitive before, when the body carried no prose. - The docblocks state the new layering and the reverse-verification direction (measured below). ## Tests All runs are at head `0d5e446e` (after merging `origin/main` at `3ddd3d0c`) unless stated otherwise. Filter direction: each package's own suite, no consumer sweep. - **`@objectstack/spec`** - `vitest run --project local`: 597 files, 17468 passed, 1 todo. - The new `src/ui/dataset-field-column-reference.test.ts` has 11 cases. - The cube precedent pin `cube-member-sql-column-reference.test.ts` stays green, with its `'*'`-on-a-cube-dimension case unchanged. - **`@objectstack/service-analytics`** `vitest run`: 162 files, 3741 passed, 45 skipped. - **`@objectstack/lint`** `vitest run`: 119 files, 5502 passed. - **`@objectstack/metadata-protocol`** `vitest run`: 200 files passed, 3 skipped; 2973 tests passed, 19 skipped. - **`@objectstack/rest`** `vitest run --project local`: 257 files, 4858 passed, 316 skipped. - **Typecheck:** `pnpm --filter PKG typecheck` exit 0 for `@objectstack/spec` (`tsc` + `check:scripts-typecheck` + `check:test-typecheck`), `@objectstack/service-analytics` (its `tsconfig` includes all of `src`, so the edited `__tests__` file is in the program) and `@objectstack/rest` (`tsc` + `check:test-typecheck`). - **`@objectstack/spec` `--project repo`, the relevant files:** `step18-rationale-merge`, `conversions-major18-merge`, `liveness/evidence`, `liveness/proof-registry`, `retired-key-migrate-sentence`, `file-description`, `root-index`, `export-list`, `category-title`, `schema-tree-freshness`, `escape-mdx` and `references-banner`. 12 files, 294 passed. - **Lint, a declared narrowing.** `eslint --no-inline-config --format json` over the 8 changed lintable files at `0d5e446e` gave 8 files, 0 errors, 0 warnings. - **Population:** from `eslint.config.mjs`, the `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block. The other changed files are `.md`, `.mdx` and `.json`. - **File count:** read from the JSON output. - **Invariance:** the config never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move a verdict on an untouched file. - The whole-repo `pnpm lint` is CI's. ## Round 2 readings (final head `fc4e91c0`; `origin/main` `3dc33b2d` merged through `os-regen-merge.sh`) These readings were taken after a container restart. The restart cut a first round-2 gate run short at `2e57fa29`. Every reading below was re-taken at `fc4e91c0`, the pushed head. - **Build.** `turbo run build` over the closures of spec, cli, service-automation, metadata-protocol, rest and client-react: 59/59 tasks. - **`@objectstack/spec`** - `vitest run --project local`: 597 files, 17465 passed, 1 todo. The counts moved with `main`'s merge. - That includes the new `src/conversions/dataset-count-measure-empty-field-removed.test.ts`, the table-wide fixture replay in `conversions.test.ts` and `retired-after.census.test.ts`. - `--project repo`: the same 12 relevant files as round 1 (`step18-rationale-merge` and `conversions-major18-merge` among them), 294 passed. - **Consumers that read the conversion table.** - `@objectstack/cli` `meta.report-order.test.ts` (unit tier): 16 passed. - `@objectstack/service-automation` `decision-overlapping-edge-conditions.pin.test.ts`: 22 passed. - `@objectstack/metadata-protocol` full suite (it hosts the stored-row seam): 200 files passed and 3 skipped; 2973 tests passed and 19 skipped. - **Not re-run this round.** `rest`, `service-analytics` and `lint`: this round's diff does not reach them (spec only), and their round-1 readings stand. - **Lint, a declared narrowing.** `eslint --no-inline-config --format json` over the 10 changed lintable files at `fc4e91c0` gave 10 files, 0 errors, 0 warnings. The population and invariance are as in round 1. - **Gates.** `dispatch-gates --commands` at `fc4e91c0` derived the same 115 families. All were run with exit codes recorded, and `--ran` reconciled them as "115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN". - `check:generated`: 15/15 up to date. - `check:migration-registry`: exit 0. - `check:adr-0087-registration`: it reads `registered dataset-member-field-expression-refused, dataset-count-measure-empty-field-removed`, both new here. - `check:skill-examples` and `check:dual-build-cjs-loads` both exited 0 this time. Both had been NOT MEASURED in round 1 for want of built packages. ## Ablations Each ablation ran from committed state, disk-verified through `scripts/ablation-replace.mjs`. Each restore was proven by blob hash equal to HEAD, an empty `git diff HEAD`, and a clean status. The predicted direction was "turns red" in all four, and that is what was observed. | leg | mutation | under mutation | restored | |---|---|---|---| | A1 | measure `field` pattern admits anything | new pin: 7 failed / 4 passed (every measure refusal, door, pattern and defineStack case red; the dimension and accept cases green) | 11 / 11 | | A2 | dimension takes `ANALYTICS_COLUMN_REFERENCE` (admits `'*'`) | 3 failed / 8 passed (the dimension refusal case on `'*'` and the two pattern pins) | 11 / 11 | | A3 | `ANALYTICS_COLUMN_PATH` admits anything, then `@objectstack/spec` rebuilt | `ablation-dist-preflight`: marker in 18 built files. `rest`: the 2 re-pinned cases red, `expected 403 to be 400` (the route's parse passes the expression to the service door, the direction its docblock predicts); 6 green. `service-analytics` door test: the contract case red, 20 green | rebuilt; marker absent from all 230 built files; tree clean | | A4 (round 2, at `fc4e91c0`) | the new conversion matches nothing (its `field !== ''` guard reads a value no row carries) | 2 failed / 239 passed: the `conversions.test.ts` fixture pin `dataset-count-measure-empty-field-removed: before → after, emits 2 notice(s)` and the stored-row pin are red; the controls are green | blob `75f4166c` == HEAD; `git diff HEAD` empty; status clean | No ablation file is left in the tree. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, run at `0d5e446e` with no paths, derived 115 families. - All 115 were run, each with its exit code recorded before any pipe. - `--ran` reconciled them: "115 derived, 114 run, 1 NOT-MEASURED, 0 UNRUN", every row carrying its recorded exit code. - **NOT MEASURED: `check:dual-build-cjs-loads`.** Reason: it exited 3 (`PREREQUISITE NOT MET`) because 44 packages outside this diff's build closure have no `dist/`, and only a full monorepo build supplies them. This diff changes no package entry, export map or build config. CI runs it on the full build. - **`check:skill-examples`** first exited 3 for want of a built `@objectstack/client-react`. After building that package it exited 0 at `0d5e446e`: 259 examples type-check. - Every other family exited 0. That includes: - `check:generated`: all 15 artifacts up to date against a stamp-matched dist. - `check:adr-0087-registration`: at the first round's head it read `registered dataset-member-field-expression-refused (new here)`. - `check:changeset-no-major` and `check:empty-changeset`. - `check:liveness`, `check:migration-registry` and `check:doc-authoring`. - `check:cross-package-test-inputs` and `check:nul-bytes`. ## Acceptance notes - **File surface, declared.** The claim named `dataset.zod.ts`, `analytics.zod.ts` "only as far as sharing needs", the ADR-0087 entry and registry, the retirement kit, pins and one changeset. Four paths go beyond that, each for the stated reason: - The new non-public module `data/analytics-column-reference.ts`: the sharing change itself, which avoids a public export. - The two consumer test files the narrowing turned red: fixture triage, re-pinned rather than loosened. - One bullet in `content/docs/data-modeling/analytics.mdx`: the skill's docs row. - **The REST door's answer moves from 403 to 400 for an expression `field`.** The route's existing `DatasetSchema.parse` refuses it first, as `VALIDATION_FAILED` naming the path. The changeset says so. The service door's 403 is unchanged. - **Studio producer (objectui, outside this repo).** - **What happens.** `DatasetDefaultInspector.tsx` at the pinned `31971ff1e` seeds a new dimension row as `{ name: '', field: '', type: 'string' }` and a new measure row as `{ name: '', aggregate: 'sum', field: '' }`. A plain count measure left with a blank Field box is saved as `field: ''`. That parsed before. Its query answered 500 on the ObjectQL path (the SQLite native path happened to accept `COUNT()`). - **After this PR** a new save of that shape is refused at `measures.N.field`, with the prescription to omit the key. A row already stored that way is repaired on load by the D2 conversion `dataset-count-measure-empty-field-removed`. - **The producer half** is to omit `field` when the box is blank. It is reported to the seat, not edited here. - **Measured, not acted on.** Neither item is filed from here; both are in the report. - A `sum` (or any non-count aggregate) over `'*'` parses on a dataset measure and answers 500 on both strategies. That is the count-only `'*'` boundary the `analytics_cube` ledger already assigns to objectstack-ai#21000's family. - A padded dimension `field` answered 200 with the dimension column missing on the ObjectQL bridge. It is now refused at parse. A stored padded row would still reach that path through the build probe; no producer of one is known. Authored by `session_01UtnxvdiN376GF3sgXwAw4d` (rounds 1 and 2). --------- Co-authored-by: Claude <noreply@anthropic.com>
…n key, its per-row expand form, a detail entry's formFields and a record:line_items block against the child (objectstack-ai#21091) (objectstack-ai#21256) Fixes objectstack-ai#21091 Clause-②: yes ## What changes `field-no-consumers` (`packages/lint/src/validate-field-consumers.ts`) called several kinds of in-use child field "inert". This PR corrects them. The per-row expand form goes through a new derivation the spec owns, as `deriveInlineGridColumns` (PR objectstack-ai#21089) did for the grid. 1. **Position 1: a `lookup`'s inline-grid join key.** A `lookup` or `master_detail` field that sets `inlineEdit` (with a resolvable `reference`) is now recorded as a behaviour read at its `inlineEdit`, whether the grid's columns are authored or derived. The renderer loads the child rows filtered on it and stamps it on save (objectui `MasterDetailForm.tsx` 1321 and 552, at the `.objectui-sha` pin `31971ff1e28f`). `master_detail` was already exempt; `lookup` now reads the same. 2. **Position 2: the derived per-row expand form.** Two new `@objectstack/spec/data` exports live in `packages/spec/src/data/inline-grid-columns.ts`. They sit in the same module as `deriveInlineGridColumns` because they share its system-name and sort-name sets. - `deriveInlineRowFormFields(def, { relationshipField?, exclude? }): string[]` is objectui's `deriveFormFields` stated as the spec's rule. It skips the same names as the grid, plus the relationship field, `exclude`, `system` / `hidden` fields and the computed types (`formula`, `summary`, `rollup`, `autonumber`, `auto_number`). It keeps `readonly` fields and every type a cell cannot edit. - `isInlineRowFormOffered({ inlineMode?, formFields?, columns? }): boolean` is the renderer's offer condition at `MasterDetailForm.tsx:847`: `inlineMode === 'form'`, or more form fields than grid columns. - The lint credits the derived row form wherever it credits the derived grid: an inline relationship field with no authored `inlineColumns`, or a `subforms` / `details` entry with no `columns`. A `details` entry is excluded when it authors `formFields`, because an authored list replaces the derived one. No copy of objectui's rule lives in the lint. 3. **Position 3 (pointer `5936875973`): a detail entry's authored `formFields`.** These names are read against the entry's `childObject`; the general walk no longer reads them against the parent. `isInlineRowFormOffered` decides whether the list is drawn, and a list the form is never offered for is a carrier. The renderer resolves an entry one of two ways, and the lint feeds the predicate what each way feeds the expand control (round 2, F1): - **Kept as authored:** the entry names both `relationshipField` and at least one column (`MasterDetailForm.tsx` 967, 1048–1052). Nothing is derived. The form factor is the declared `inlineMode`, or none at all, so the predicate decides exactly. With an omitted `inlineMode`, the form is offered only when the list is longer than the grid. - **Derived:** anything else (1055–1066). A declared `inlineMode` is kept. An omitted one is resolved from the relationship's `inlineEdit`, else from the child's shape. The lint does not reproduce that resolution, so with an omitted mode the list is credited as drawn. With a declared mode, the predicate decides whenever the grid can be counted. 4. **Position 4 (pointer `5940763140`): a `record:line_items` block.** Its raw `properties` are read as one child entry: authored `columns[].name`, `relationshipField` and `amountField` against `childObject`, with `totalField` left on the parent. objectui `LineItemsPanel.tsx` at the pin reads these keys this way. It derives no grid and offers no row form. `RecordLineItemsProps` is not imported. **Round 2, flag B:** the block's `sort` and `filter` are now walked in the `childObject`'s context. `LineItemsPanel` applies them to the child query (366–379, 516–521). Since PR objectstack-ai#21244 landed `RecordLineItemsProps`, the contract declares `filter` as the ViewFilterRule array. The panel's lowering also takes the field-keyed map, and the lint reads whichever is authored. Both forms are pinned. **Fixture triage (round 1).** Six tests in the `[objectstack-ai#20951]` site-2 block pinned that a derived carrier leaves the `json` and `readonly` child fields inert. The derived row form now draws them, so their expected sets were re-judged: `DERIVED` keeps only the `hidden` field, and `NO_ROW_FORM` keeps the old set for the three cases that draw no derived row form. ## Round 2: the contract review `5942628181` (FAIL) and what this head does about it - **F1, fixed.** The round-1 lint credited an authored `formFields` list as drawn whenever `inlineMode` was omitted. On the kept-as-authored path that is false: the renderer leaves the mode undefined, and line 847's count decides. The lint now decides that path with `isInlineRowFormOffered({ inlineMode: undefined, formFields, columns })`. The docblock and test titles state both paths. The test's own fixture (`relationshipField` and two columns, one form field) now pins `itm.notes` as `carrier-only`. - **Flag B, measured and closed.** See position 4. The probe confirmed it: the three child fields read only by a block's `sort` / `filter` were inert, and the same-named parent fields were credited in their place. It is pinned with two enumeration rows (`sort[].field`, `filter[].field`) and three unit tests. - **Flag A, measured; not closed on this surface.** Reading below. ### Flag A: a row form opened with no field list This happens when an authored grid is in the `form` factor and has no `formFields`. That covers authored `inlineColumns` with `inlineEdit: 'form'`, or with `inlineEdit: true` and a child the smart default sends to `form`, and a detail entry kept as authored with `inlineMode: 'form'`. The renderer then opens the child's `ObjectForm` with no `fields` (`MasterDetailForm.tsx` 1821). That form draws the child's generated field set (`ObjectForm.tsx` 961) through `filterSystemFields` (`autoLayout.ts` 231): every field except the server-owned names, `hidden` fields and `readonly` fields, laid out by `fieldGroups` when the child declares any. **Probe reading (all three heads below):** `pg_line.note_g`, `ph_line.body_h`, `ph_line.note_h` and `pi_line.note_i` are reported inert, and the renderer draws them. `pg_line.ro_g` (`readonly`) and `pg_line.hid_g` (`hidden`) are reported inert, and the renderer does not draw them either. The reach is confirmed. **Why it does not close here:** 1. Crediting it needs a spec-owned statement of the default object form's field set: `ObjectForm`'s generated set, the server-owned roster from objectui `sanitize.ts`, the `hidden` and `readonly` filters, and the `fieldGroups` layout. That is a new cross-repo contract with its own differential and its own objectui consumer. 2. The `inlineEdit: true` arm also needs the smart default (`resolveInlineMode`: the form-only types, the two-rich-field threshold and the eight-field threshold) promoted to the spec. 3. It meets this rule's documented posture. The default layout is never a site (`creditFieldGroupLayout`: only a KEYED section counts), because the platform's default form draws every visible field of every object. The probe's own control `pa_order.buyer` is drawn by `pa_order`'s default form and reported by design. Crediting the same form when a parent opens it as a row editor makes the verdict depend on which door opens it. That is a decision about the rule's contract, not an omission in this diff. So the module note and a pinned boundary test state the position: an authored grid in the `form` factor with no `formFields` keeps those child fields reported. The enumeration pin's sentence now reads "the form the spec derives, and an authored `formFields` list the form is offered for". The position goes to a point card the seat files. The report carries the options. ## The spec functions against objectui's rule (round 1, unchanged) The differential ran the spec functions against `deriveFormFields` and line 847's expression, both read from the pinned files (`deriveMasterDetail.ts` blob `90aa44c9`, `MasterDetailForm.tsx` blob `7a96a130`). The offer expression was evaluated from the source text. - **`deriveInlineRowFormFields`: 100,004 cases, 0 mismatches.** The cases were objectui's 4 fixtures plus 100,000 random definitions: null and string field definitions, array-shaped `fields`, non-spec type names, truthy and falsy flags, prototype-ish names, and random `relationshipField` / `exclude`. - **`isInlineRowFormOffered`: 300,012 cases, 0 mismatches.** - **Subset property: 0 violations.** The derived grid is always a subset of the derived form. - **Lit control: 648 of 2,000 mismatches.** The same harness was run against a function that is not the rule, so the harness can fail. ## Evidence **The door: `os validate --json` on a `defineStack` probe stack.** Three heads were measured, each built from source: - `a7d9768e`, the card's base, in a separate worktree; - `1d1258a5`, the round-1 head; - `a87f03e1`, this head. All three were run with the same probe file (its `filter` blocks in the rule-array form). All three exit 0 with `valid: true`. `field-no-consumers` findings: 32, 17, 17. | field | a7d9768 | 1d1258a | a87f03e | position | |:--|:--|:--|:--|:--| | `pa_order_note.order` / `pa_ticket_line.ticket` / `pb_case_comment.case_ref` / `ph_line.header` (`lookup` + `inlineEdit`) | inert | — | — | 1 | | `pb_invoice_line.notes` / `.config` / `.frozen`, `pb_case_comment.body`, `pb_memo_line.long_note` | inert | — | — | 2 | | `pc_line.memo` (detail `formFields`, `inlineMode: 'form'`) | inert | — | — | 3 | | `pc_header.memo` (parent twin) | — | inert | inert | 3: was credited in the child's place | | `pd_line.memo2` (declared `grid`, 1 field vs 2 columns) | inert | carrier-only | carrier-only | 3 | | `pf_line.memo_f` (kept as authored, no `inlineMode`, 1 field vs 2 columns) | inert | — | carrier-only | 3, F1 | | `pe_line.qty_e` / `.note_e` / `.header` / `.amt` (`record:line_items` columns and keys) | inert | — | — | 4 | | `pe_header.amt` (parent twin) | — | inert | inert | 4 | | `pk_line.srt_k` / `.flt_k` / `.flt2_k` (block `sort`, two blocks' `filter`) | inert | inert | — | 4, flag B | | `pk_header.srt_k` / `.flt_k` (parent twins) | — | — | inert | 4, flag B: were credited in the child's place | | `pg_line.note_g`, `ph_line.body_h` / `.note_h`, `pi_line.note_i` (default form) | inert | inert | inert | flag A: not credited, see above | | `pg_line.ro_g` / `.hid_g` (`readonly` / `hidden`) | inert | inert | inert | flag A: not drawn either | | `pc_line.position` (detail `sortField`) | inert | inert | inert | no lint read; see notes | | `pa_order.buyer`, `pb_invoice_line.secret`, `pe_line.unused_e`, `pk_line.unused_k` | inert | inert | inert | controls | (— means not reported.) **A real producer: `examples/app-showcase`.** There are 52 findings at `a7d9768e` and 52 at `a87f03e1`, with identical verdict sets. PR objectstack-ai#21244 changed its `record:line_items` page in between, and that block has no `sort` or `filter`. **Tests at `a87f03e1`** (the head of this PR): - `pnpm --filter @objectstack/lint exec vitest run`: 119 files, 5,572 tests passed. The `validate-field-consumers.test.ts` file has 126 tests, including the `[objectstack-ai#21091]` block: positions 1 to 4, the flag-A boundary, and the enumeration pin's 13 rows, each paired with a control. - `pnpm --filter @objectstack/spec exec vitest run --project local`: 597 files, 17,483 passed and 1 todo. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 243 files, 3,439 passed, with the CLI closure built with declarations. The integration tier is declared to CI. - `pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck`: both exit 0, and `check:test-typecheck` is OK for both. - Filter direction: `@objectstack/spec`, `@objectstack/lint`, and the downstream lint consumer `@objectstack/cli`. **Reverse verification and ablations.** Each was committed first, made through `scripts/ablation-replace.mjs` or a blob restore, and restored to the HEAD blob with `git diff HEAD` empty. All were predicted red, and all were red. - Round 1: the lint source restored to the base blob `3efd1236` failed 29 of 115 tests. The spec row form made to drop `readonly` failed 2 of 20. - Round 2, at `a87f03e1`, flag B: the panel's `sort` / `filter` read switched off failed exactly the 5 flag-B tests (3 tests and 2 pin rows). - Round 2, at `a87f03e1`, F1: the kept-as-authored decision switched off failed exactly the F1 carrier test. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 8 paths and 86 commands at `a87f03e1`. Every one was run. `--ran` reports "86 derived, 86 run, 0 NOT-MEASURED, 0 UNRUN", and all 86 exited 0. `check:generated`: all 15 artefacts are up to date. The two spec shards gain exactly the two names each. **Base.** `origin/main` moved under generated files three times and was merged each time through `scripts/pm/os-regen-merge.sh`: at `1d1258a5`, `ee505255` and `6084ce01`. The last merge brought PR objectstack-ai#21244's `RecordLineItemsProps`. No merge owed a regeneration, and the delta against `origin/main` is exactly this PR's 8 paths. Since then, `origin/main` has moved by 4 commits, none of which touches a generated artefact or one of the 8 paths. ## Acceptance notes - **Exports.** There are two new names, both functions: `deriveInlineRowFormFields` and `isInlineRowFormOffered`. No schema accepts or refuses anything new. - **For the objectui ④ child:** - `deriveFormFields(childSchema, opts)` equals `deriveInlineRowFormFields(childSchema, opts)` on every measured input. - Line 847's expression equals `isInlineRowFormOffered({ inlineMode: d.inlineMode, formFields: d.formFields, columns: d.columns })`. - The verdicts are above. - **`sortField` (pointer position 3), probe reading.** `pc_line.position` is inert at all three heads. At the pin the renderer only stamps it (`GridField.tsx:735`). It loads rows with `$filter` and `$top` and no ordering, so it never reads the field. objectui `0a3e5409f` retired the authored key after the pin, and no lint read was added. The general walk still reads `details[].sortField` against the parent. That reading leaves with the key at the next `.objectui-sha` bump. - **Flag A** goes to a point card the seat files. The pin sentence and a boundary test state what this PR covers. - **Kept as stated:** an omitted `inlineMode` on the DERIVED path (the renderer's smart default), and a derived grid with no `relationshipField`, both credit an authored list as drawn. - **"Not in this card"** stays out: the explicit `form.subforms` override, and a `subforms` entry with no `relationshipField`. - **Changeset.** `@objectstack/spec: minor`, because `Clause-②: yes` takes at least minor. `@objectstack/lint: patch` follows PR objectstack-ai#21089 and PR objectstack-ai#21215. The lint bullets now state the round-2 reads. The rule's message and hint text are unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…export options object, and a bare format array is refused (objectstack-ai#21229) (objectstack-ai#21287) Fixes objectstack-ai#21229 Clause-②: yes Dispatched by the PM claim `5943166878` (PM loop round 1, `domain:spec` seat 1), on the triage ruling `5939380297`. `ComponentPropsMap['object-grid'].exportOptions` was `z.unknown()`. It now takes `ListViewExportOptionsSchema`, the list view's strict five-member export options object, **by identity**. It does not take the list view's union, whose legacy bare-array arm lifts to `{ formats }`. A bare array is refused with the object form named. The changeset carries the `(narrowing)` arm, the BREAKING banner at `minor`, and the ADR-0087 marker `registered ui-object-grid-export-options-closed`. It is D3 only; the reading is below. ## What changed - **New non-barrel module `packages/spec/src/ui/list-view-export-options.ts`.** It holds the export options block, moved verbatim out of `view.zod.ts`: the retired-`'pdf'` prescription, the `csv` / `xlsx` / `json` format enum, and the strict five-member object. - There is one addition. The object's own error map answers an `invalid_type` on an array input with a prescription naming `{ formats: ['csv', 'xlsx'] }`. - It is the object's map because that is the only map a type failure at this position consults. A wrapper's or the enclosing row's map is never reached, and an object-level refinement never runs once a property has failed its type. - The object is built exactly as `strictObject()` builds one: `closedObject(z.object(shape, { error }).strict())` with the same registered `strictObjectError` declaration. The `prime` handle is forwarded, so `closedObject`'s terminal unknown-key contract is kept. - **New non-barrel module `packages/spec/src/ui/view-history.ts`.** `VIEW_HISTORY` moved here, verbatim, so the moved block keeps the refusal sentence it has always carried. `view.zod.ts` imports both modules, and its 53 `VIEW_HISTORY` uses are unchanged. - **`component.zod.ts`:** `exportOptions: ListViewExportOptionsSchema.optional()`. The "Unvalidated here" describe text is gone. A docblock records the ruling and the door reading. - **D3 entry** `18.ui-object-grid-export-options-closed.ts`, regenerated into `migrations/registry.ts` by `gen:migration-registry`. - **`STEP18_RATIONALE` fragment** `ui-object-grid-export-options-closed`, `order: 59`. `main` holds 57 (PR objectstack-ai#21244) and 58 (PR objectstack-ai#21240) at the merge `b91e40bc89`. It is inserted at its sorted position. - **Pin file `component-object-grid-export-options-members.pin.test.ts`, rewritten.** The objectstack-ai#17166 version asserted the key was still unvalidated, so that this change would red there and be decided deliberately; it did. The new file holds the triage pins: - **identity:** the row's inner schema is the very instance the list view's union holds as its object arm, read from the union rather than imported by name, and it is not the union; - **bare array refused:** `invalid_type` at `exportOptions`, with the object form named. The control is the same array on a list view, which still lifts to `{ formats: ['csv'] }`; - **object form accepted:** all five members, `{}` and absent; - **a format outside the enum refused:** `invalid_value` at `exportOptions.formats.1`, and `pdf` with its retirement text; - **an undeclared key refused:** `unrecognized_keys` at `exportOptions`, naming `this export options block` and the `maxRecord` → `maxRecords` rename. - **Regenerated:** `content/docs/references/ui/component.mdx` (the row's type, and a new nested-shape table) and `docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md` (see the strictness-ledger note under Acceptance notes). ## Measured: why a non-public module, not an export (Zone 2 item 1) - `ui/index.ts` is `export * from './view.zod'`, so any export from `view.zod.ts` is public. - **Measured.** I added `export { ListViewExportOptionsSchema }` to `view.zod.ts` through `scripts/ablation-replace.mjs` in wrap mode, then rebuilt spec. The first attempt was refused by the tool as a no-op, because the anchor was inside the replacement; it was re-anchored and re-run. Results: - `check:api-surface` turned red with `./ui + ListViewExportOptionsSchema (const)` ("0 breaking, 1 added"), and `check:export-origins` turned red; - the build also wrote `ui/ListViewExportOptions` into `json-schema.manifest/ui.json` (a new published JSON Schema def) and 5 `ui/ListViewExportOptions:*` rows into `authorable-surface/ui.json`. - The file was restored, blob equal to HEAD and `git diff HEAD` empty. The two dirtied artifacts were restored with `git checkout HEAD`, and after a clean rebuild `check:api-surface` reads "unchanged ✓". - **objectui.** At the pin `31971ff1e28f`, `packages/types/src/__tests__/export-options-spec-parity.test.ts` asserts `expect(specUi.ListViewExportOptionsSchema).toBeUndefined()`. An export would red that test at objectui's next spec bump. - **With the non-barrel module**, `check:api-surface`, `check:export-origins`, `check:declaration-map` and `check:authorable-surface` are unchanged. One declaration; zero public surface. This is the `analytics-column-reference.ts` / `analytics-carrier-filter.ts` / `section-group-reference.ts` precedent. ## Measured: the ADR-0087 disposition is D3 only (Zone 2 item 2) Every pre-PR shape that the pinned renderer handles and the PR refuses was put through every door, before (`f148852752`) and after (`8b2c2bb558`, the schema commit, same `src/ui` as HEAD). The reading uses the built `dist`, `getMetadataTypeSchema('page')` (the save door's per-type parse), `defineStack`, the props gate `validateComponentProps`, and `runtimeAuthoringRulesFor('page')`. | shape (on an `object-grid` node's `properties`) | at the pinned renderer (`ObjectGrid.tsx` at `31971ff1e28f`) | row before → after | save door | `defineStack` | props gate after | |:--|:--|:--|:--|:--|:--| | `['csv']` | `!!exportOptions` is true, so the menu shows the csv/json default; the list is dropped | accept → `invalid_type` | ok | accepted | warning `component-props-invalid` | | `{ formats: ['csv'], foo: 1 }` | renders; `foo` is never read | accept → `unrecognized_keys` | ok | accepted | warning `component-props-unknown-key` | | `{ formats: ['pdf'] }` / `['xml']` | the value is hidden from the menu with a `console.warn` | accept → `invalid_value` | ok | accepted | warning `component-props-invalid` | | `null` | `!!null` is false, so no menu, the same as absent | accept → `invalid_type` | ok | accepted | warning `component-props-invalid` | | `true`, `'csv'`, `{ formats: 'csv' }`, `{ maxRecords: -1 }`, `{ streaming: 'false' }` | renders, with the default or a misread | accept → refused | ok | accepted | warning `component-props-invalid` | - **Lit controls, same run.** An undeclared `object-grid` prop gives the props-gate warning `component-props-unknown-key`. An undeclared page key is REFUSED at the save door and THROWS in `defineStack`. The accepted object forms (`{ formats: ['csv','xlsx'] }`, `{}`, all five, absent) give no finding anywhere. - **The runtime publish gate for `page`** runs one rule, `validatePresetComparands`. The props gate is `tier: 'advisory'`, `surfaces: CLI_ONLY`. - **The renderer.** At the pin, objectui runs its zod mirror only in the `objectui validate`/`check` CLI. `SchemaRenderer` runs a structural `validateSchema` in dev only. - **So nothing on the save or load path refuses any of these shapes.** A stored page saves and loads, and no conversion has a load-path refusal to pre-empt. - **Lossless rewrites.** The undeclared key, `pdf` and `null` have one (delete it), but nothing stops loading. The bare array has none that both keeps today's menu (`{}`) and honours the author's list (`{ formats }`), and that choice is the upgrader's, which the D3 entry states. Triage also ruled out a lift. ## Census (Zone 2 item 3), on `f148852752` - **Zero `object-grid` blocks author `exportOptions`** in `examples/**`, the package fixtures, `content/docs/**` and `skills/**`. - **Control:** the same census finds 10 authored `object-grid` blocks. Nine are TypeScript nodes (two showcase pages and seven package-test fixtures). One is the YAML example in `content/docs/protocol/objectui/layout-dsl.mdx`. The `exportOptions` matcher is lit on the 4 list-view authorings: `app-crm` ×2, the showcase task view, and the lint showcase fixture. - **`skills/**`:** nothing teaches `exportOptions`. `skills/objectstack-ui/rules/pages.md` names `object-grid` in prose only, so no Tier H follow-up is owed. - Nothing needed respelling. ## objectui (Zone 2 item 4, Post-Task Checklist objectstack-ai#4) Nothing the pinned objectui imports moves: - `check:api-surface` is unchanged, and no export was removed or renamed. - `ListViewSchema.shape.exportOptions` is still a two-arm union with one five-key object arm, which is what objectui's `SPEC_EXPORT_OPTIONS_OBJECT_SHAPE` peel reads. - `ListViewExportOptionsSchema` is still not exported, so objectui's floor test holds. - No pinned objectui test parses the row with `exportOptions` and expects a bare array to pass. Six pinned tests mention `exportOptions` near the row; two of them carry only prose that will go stale (see Acceptance notes). ## Changes outside the row, stated - **The list view's nested message.** The list view's `exportOptions` accepts and lifts exactly what it did, and its top-level messages are unchanged. Only when a bare array also fails the array arm (`['docx']`) does the object arm's nested branch message read the new prescription instead of `Invalid input: expected object, received array`. Measured on `dist`. Both carriers read one declaration, and the text is worded to be true on both. The changeset says so. ## Tests and gates: all on `032865c93c` (HEAD, the merge of `origin/main` `b91e40bc89` through `os-regen-merge.sh`) - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: **597 files passed, 17475 passed, 1 todo**. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `check:test-typecheck` is OK (52 files / 246 errors / 135 pinned signatures, unchanged), so the rewritten pin compiles under `tsconfig.test.json`. - **Contract-face fixture triage.** These are consumers of the spec, the downstream (`...@objectstack/spec`) direction, limited to the three the dispatch names: - `@objectstack/lint` (the props gate): **119 files / 5515 tests passed**; - `@objectstack/metadata-protocol`: **200 passed + 3 skipped files / 2973 passed + 19 skipped tests**; - `@objectstack/spec`: as above. - No fixture in any of them needed a change. - `pnpm --filter @objectstack/spec check:generated`: 15 of 15 up to date after `--fix` regenerated the 2 it proved stale (`check:docs`, `check:strictness-ledger`). - **`dispatch-gates.mjs --commands`** (no paths) derived 112 commands; all 112 ran and exited 0. - Six first exited 3 with PREREQUISITE NOT MET, because `lint`, `client-react` and `objectql` had no `dist`: `check:doc-formula-expressions`, `check:doc-security-posture`, `check:skill-examples`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads` and `check:lean-entry-closure`. Each re-ran green once the dists existed. - `--ran` with `cmd :: exit N`: "112 derived, 112 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)". - **Reverse verification, cross-package type.** A scratch `probe.ts` typed against the rebuilt `dist/ui/index.d.mts`, using `ObjectGridProps`, was compiled with `tsc`: - `exportOptions: ['csv']` gives `TS2559`; - `{ formats: ['xml'] }` gives `TS2322`; - `{ formats: ['csv'], maxRecord: 1 }` gives `TS2561`; - the control `{ formats: ['csv','xlsx'], maxRecords: 10 }` compiles. - At the base the key was `unknown` (the reference page rendered `any`). - **NOT MEASURED, declared to CI:** the 6 path-scheduled CI jobs (Test Core shards, Temporal Conformance, Dogfood Regression and Verify, Build Core, Build Docs), the 4 workspace type-check lanes, and the 54 artifact-roster families the derivation lists outside its total. ## Acceptance notes - **The strictness ledger's scope.** The ledger (`check:strictness-ledger`) counts `.zod.ts` files only. The moved block is one CLOSED site and now lives in a non-`.zod.ts` module, like the other non-barrel helpers, so the regenerated `ui/` counts read 189 → 188 sites and 179 → 178 strict. The strip count, which is the ratchet's target, is unchanged at 7. Naming the module `.zod.ts` would have kept the site counted, at the price of a hand-written ledger row and of opting a non-public module into the `.zod.ts` generators' discovery. - **Stale prose in objectui.** At the pin, two objectui test files say the spec row is `z.unknown()`: the `ObjectGrid.exportOptionsKeys.test.ts` docblock, and the `object-grid.exportOptions` row in `registry-inputs-spec-parity.test.ts`'s `MEMBER_PINS`. Neither asserts it, so nothing goes red. Once the spec version carrying this lands, both describe a past state. Carrier: objectui's next spec pin-bump PR. Noted, not filed. - **objectui's `properties` bag.** On objectui `origin/main`, the bag arm from objectui#11399 judges the bag by `ComponentPropsMap['object-grid']` by reference. Once objectui installs this spec, its `objectui validate` refuses a bare array in the bag, as its flat mirror has since objectui#7762. No objectui change is owed beyond the pin bump. - **A public export, if objectui later wants one.** objectui's export-options parity test says "When upstream exports the symbol, derive from it and delete both the mirror". Publishing `ListViewExportOptionsSchema` stays possible as its own decision. It moves `api-surface` (+1 const), `json-schema.manifest` (+1 def) and `authorable-surface` (+5 rows), as measured above. It is not done here. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21142
Clause-②: yes
Seam: renderer
@object-ui/plugin-formLineItemsPanel→@object-ui/fieldsGridField(bindscolumn.name) ← producer: objectstack showcaserecord:line_itemscolumns[].fieldThe showcase project page's Tasks grid keyed all five of its columns
field. The line-items grid binds a column byname, so every cell rendered empty. Nothing refused it:record:line_itemshad noComponentPropsMaprow, so the component-props gate skipped its props bag as unregistered. This PR fixes the producer and adds the row (the claim's call, per triage5929028089). From here on afield-keyed column is refused at authoring, with the rename toname.What changed
examples/app-showcase/src/ui/pages/project-detail.page.ts: the five columns are keyedname(title,status,priority,estimate_hours,due_date). Nothing else on the page moved.packages/spec/src/ui/component.zod.ts: new rowComponentPropsMap['record:line_items']=RecordLineItemsProps, a strict shape of the fifteen keys objectui'sLineItemsPanelreads (measured below):relationshipFieldis required, and so iscolumns(at least one). Nothing on this panel derives either one.childObjectis optional, because the component-leveldataSourcebinding can supply it.filter,sortandlimittake the declarations every sibling door takes: the ViewFilterRule array, the SortItem array and a positive integer.object-master-detail-formdetail entry take that entry's types and alias table.addLabel,sortField,formFields,inlineMode) are refused with aguidancereason.RecordLineItemsPropsandRecordLineItemsPropsParsed.columnsISInlineGridColumnSchema, by reference and not a copy (Zone 2 Add Changesets and GitHub Actions automation #4: same shape, see below). The retiredfieldspelling is refused by name with the prescription namingname. One carrier difference is stated in thedescribe(): this panel does not hydrate a column from the child field. For the same reason,defineStack's identity-only check (collectHydratedInlineColumnErrors) is deliberately NOT extended to this block, and a control pin holds that.packages/spec/src/ui/component-type-vocabulary.ts:record:line_itemsleavesSTRING_ARM_REGISTERED_TYPES, which is now empty. The export stays, and its docblock records why it is empty. The type stays KNOWN through its row.ui-record-line-items-props-closed, plus a step-18 rationale fragment (order 57).gen:migration-registryregenerated the registry. No D2 conversion: page-componentpropertiesis not parsed on the save or load path, and the census found one producer, respelled here.validate-component-props.test.ts:record:line_itemsleaves the unregistered-skipit.each. A new suite asserts thefield-keyed columns firecomponent-props-unknown-keyat...properties.columns.N.field, withcomponent-props-invalidat...columns.N.name. Thename-keyed control is silent.component-type-vocabulary.test.ts: known through the row, not on the ledger, not an enum member.inline-grid-column-carriers.test.ts: a fourth-carrier section covering identity of the column element, thefieldrefusal (codeunrecognized_keysat path['columns', 0]), the currencyscalerefusal, the bogus key,relationshipFieldandcolumnsrequired,.min(1), the alias and guidance refusals, and full-read-set and showcase controls. A last control shows thatdefineStackdoes not judge an identity-only line-items column, while the same column underobject-master-detail-formis judged.examples/app-showcase/test/project-detail-line-items.test.ts: the five columns are keyedname, each names ashowcase_taskfield, the block parses againstRecordLineItemsPropswith its keys intact, and nofield-keyed line-items column exists anywhere in the showcase.validate-component-props.tsheader (the skip list and its "earlier editions" history),validate-component-types.test.tscomment, and thevalidate-page-field-bindings.test.tstest title ("skips a component type its descriptor table does not carry"). Those are comments and a title only; no lint behaviour changed.dropped-refinements.baseline.json: new siteui/RecordLineItemsPropsatcolumns.elementandfilter.element, plus its two counts.api-surface/ui.json,export-origins/ui.json,declaration-map/ui.json,authorable-surface/ui.json,json-schema.manifest/ui.json,content/docs/references/ui/component.mdxandindex.mdx, anddocs/audits/...strictness-ledger.counts/ui.md..changeset/21142-line-items-columns-name.md:@objectstack/specminor, BREAKING,Clause-②: yes (narrowing), ADR-0087registered ui-record-line-items-props-closed. What reads it is the component-props gate (advisory findings onobjectstack validate/build/lint). The stored-page save and load path does not parseproperties.Measurements
Premise: holds. At
origin/main1ecb871beb,project-detail.page.ts:76authorsamountField: 'estimate_hours'and:79–:104author fivecolumnskeyedfield:.record:line_itemswas the only entry ofSTRING_ARM_REGISTERED_TYPES(component-type-vocabulary.ts:68), and its row-lessness was pinned invalidate-component-props.test.ts:495.Read set at the
.objectui-shapin31971ff1e28f(objectuipackages/plugin-form/src/LineItemsPanel.tsx;SchemaRendererhoistspropertiesontoschema). A count ofschema.KEYreads gives exactly fifteen keys:childObject:319,:327,:498,:516,:638,:673,:702,:778relationshipField:515,:674columns:702parentObject:221parentIdandrecordId:228amountField:669,:703totalField:667,:669,:703title:722readonly:706,:707,:723,:810minRows:704maxRows:705filter:366sort:368,:377limit:341,:437The wrapper adds no key.
ElementDataSourceGate.tsxreads the node-leveldataSourceplus the samefilter/sort/limit(:421,:434,:445). The mappingRECORD_LINE_ITEMS_DATA_SOURCE(plugin-form/src/index.tsx:556) writes the binding'sobjectontochildObject.requiredPermissionsandaria, which other record rows declare, have no read here, so they are not declared.objectui
main(d59f11c0d3dc, pin is an ancestor:merge-base --is-ancestorexit 0):totalFieldis set (total_field: schema.totalField ? schema.amountField || 'amount' : undefined). Onmain(objectui55a12a8e1, round 8) it appears wheneveramountFieldis named. So the showcase'samountFieldwith nototalFielddraws a footer only once the console pin moves past that commit.GridFieldonmaindeclaresGridColumn = InlineGridColumn, which is the spec's type by reference (objectui75dcc81c3).0a3e5409f(gridsort_field) changesGridFieldandMasterDetailForm, not anything this block reads or hands the grid.main.Column shape (Zone 2 #4): at the pin,
GridField.tsx'sGridColumninterface declares exactly the twenty keysInlineGridColumnSchemadeclares (name,label,type,options,width,required,prefix,step,reference,displayField,idField,multiple,accept,defaultHidden,computed,expr,scale,autofill,readonlyWhen,requiredWhen). Same shape, so the row references the schema by identity. The one difference belongs to the carrier:LineItemsPanelhandscolumnsstraight toapplyColumnPermissionsand then the grid (:702), with nohydrateColumnsstep. An identity-only{ name }column therefore draws as a text cell headed by its name, and thedescribe()says so.Census (Zone 2 #5) at
1ecb871beb, matchertype: 'record:line_items':examples/: 1 producer, the showcase page, 5field-keyed columns, respelled here.content/docs/: 0 blocks. One prose tag-list mention inui/react-pages.mdx:38.packages/non-test: 0.record:*blocks inexamples/(3record:details, 2record:highlights, 1 eachrecord:path,record:quick_actions,record:alert).Served showcase page end to end: NOT MEASURED. Neither checkout has a console build (
packages/console/distis absent in both).pnpm devrunscheck:console-shafirst, and producing that build needs a full objectui build at the pin, which this dispatch holds read-only. What is measured instead:namekeys intact (showcase test).record:line_itemsrewrite (git grepinpackages/spec/src/conversions: 0 hits), so no load-time conversion on this side intervenes.Tests (at HEAD
c2b91013)All through
scripts/pm/os-verify-lock.sh, exit codes read from itsVERDICTline. The tree is the merged one:origin/main62b90d74merged throughos-regen-merge.sh, plus the regeneration commit.pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2pnpm --filter @objectstack/spec run typecheck(tsc + scripts + test layer)pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2pnpm --filter @objectstack/lint run typecheckpnpm --filter @objectstack/sdui-parser exec vitest run --maxWorkers=2pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2pnpm --filter @objectstack/example-showcase exec vitest run --maxWorkers=2pnpm --filter @objectstack/example-showcase run typecheckpnpm --filter @objectstack/spec check:generatedpnpm --workspace-concurrency=2 --filterwith thePKG^...closure of lint, metadata-protocol, sdui-parser and the showcase, plus@objectstack/spec build).protocol.meta-types-degenerate-derivation.test.ts(the served-schema count pins) is green unchanged. The new row moves no count:pagestill serves 24 top-level properties, becausepropertiesis an open record.2479fb67/742c6970): the same suites were green. Six transient failures — module-not-found on@objectstack/spec/*and@objectstack/platform-objects/*— came from a concurrent dist rebuild by the gate run. They were re-run on stable dists: green (4 files / 30 tests, and 30 files / 391 tests).eslint --no-inline-config --format jsonover the 12 changed.tsfiles reports 12 results, 0 errors, 0 warnings, and none ignored, so all 12 are in the config's population.eslint.config.mjsnever enables type-aware linting (its:327-329), so this diff cannot move the verdict on any file it did not touch. The repo-widepnpm lintis CI's.Reverse verification (ablation)
The map row
'record:line_items': RecordLineItemsProps,was deleted throughscripts/ablation-replace.mjs(anchor 1 → 0, blob35459aca2181→d052475a42e4), committed state first. Thenpnpm --filter @objectstack/spec build.ablation-dist-preflight.mjs --absentreported the marker absent from all 230 built files, and the tree carried only the source mutation.component-type-vocabulary.test.ts+inline-grid-column-carriers.test.ts→ 8 failed / 55 passed. These are the vocabulary pin and seven of the eight new carrier tests. The eighth, thedefineStackcontrol, reads no row and stays green as intended.dist):validate-component-props.test.ts→ 1 failed / 49 passed. "reports afield-keyed column" saw zero findings, which is the pre-fix silence. Itsname-keyed control stays green (vacuously under the ablation).RecordLineItemsPropsdirectly rather than through the map.git checkout HEAD, blob back to35459aca2181== HEAD,git diff HEADempty, whole-treegit status --porcelainclean. Rebuilt spec, preflight "marker present in 14 built files" and "working tree clean against HEAD". Lint suite back to 50/50.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths) atc2b91013: 113 commands, the same list as the pre-merge derivation.c2b91013, sequentially, each exit code captured before any pipe: 113 × exit 0. This includescheck:adr-0087-registration(registered ui-record-line-items-props-closed (new here)),check:changeset-no-major,check:nul-bytes,check:issue-citations,check:doc-authoring,check:spec-parsed-alias,check:migration-registry,check:dual-build-cjs-loads, andcheck:type-check-debt(re-measure, 336s).dispatch-gates --ranreconciliation: 113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN.ffcd210a), taken alongside a closure build, did not measure seven gates. Five answered PREREQUISITE NOT MET (exit 3).check:dts-closurenamed a package whose declarations were mid-rebuild.check:type-check-debthit a 420s cap. All seven are in the 113 × exit 0 above.pnpm lint.Acceptance notes
amountFieldfooter differs between the pin and objectuimain(above). This is not a defect here: the console pin bump carries it in.parentIdwins overrecordId(LineItemsPanel.tsx:228). Both are declared as measured, on theobject-master-detail-forminitialValues/initialDataprecedent, and thedescribe()names the precedence. Retiring one is a separate enforce-or-remove question; not filed (no reach measured).packages/types/src/zod/public-blocks.zod.ts:167, which says "the spec carries no row", and the registryinputsfor this block, which declare 5 of the 15 keys.field-no-consumers(validate-field-consumers.ts) walks child collections by the keyssubforms/details, so it does not credit arecord:line_itemsblock's column names to the child object. Reach was not measured: the showcase task fields are consumed elsewhere. Noted only.Generated by Claude Code