Repository navigation
Commit 3d886ee
feat(spec,lint): a field declares conditionalFormatting cell rules over value and record, in the list view's own rule element (#22546)
Fixes #22228
Clause-②: yes (widening)
The maintainer-directed field-level formatting block from the decision
discussion on objectstack-ai/objectui#11815 (the director seat's
proposal, answered 「同意」). Spec and authoring gate only; objectui's cell
renderer is its own card after the spec publishes.
## What
- **`FieldSchema.conditionalFormatting`** (optional): an ordered list of
`{ condition, style }` rules. The first rule whose CEL `condition` holds
applies its CSS `style` map to that field's cell, wherever the field
renders. A list view's row rules still style the row, and both may
apply.
- **One rule element, no second copy.** The `{ condition, style }`
element was declared inline in `ListViewSchema`. A field definition
cannot import it from there (`ui/view.zod.ts` imports
`data/field.zod.ts`, so the reverse import is a cycle and a data-on-UI
dependency). It moved to
`packages/spec/src/shared/conditional-formatting.zod.ts` as
`ConditionalFormattingRuleSchema`, and both
`ListViewSchema.conditionalFormatting` and
`FieldSchema.conditionalFormatting` mount it. The `object-grid` /
`object-kanban` blocks still reuse the list view member by identity, so
they mount it too. Not exported from the `shared` barrel: no public
export is added.
- **The scope is `value` and `record`, nothing else.**
`@objectstack/lint`'s field walk (`validate-expressions.ts`) judges each
`conditionalFormatting[i].condition` at `os build`, `os validate` and
the object save door: it must parse, read fields as `record.FIELD`
(never bare), name declared fields, and read no root but `value` and
`record`. `value` reaches the shared validator through
`ExprSchemaHint.roots` for this one call; `SCOPE_ROOTS` is not widened.
A new root verdict, `fieldFormattingRootIssue`, refuses `previous`,
`parent`, the user roots and every other judged root, in its own words
(the field-rule family's message names `previous` / `parent` as bound,
and here they are not).
- **No new style vocabulary.** The `style` map is the row block's, by
the identity above.
## Zone 2 mechanism assumptions, measured
1. **Where the rule item schema lives on `ee8751d41e`:** inline in
`ListViewSchema` (`ui/view.zod.ts`, a `strictObject` inside `z.array`),
with `ListViewSchema.shape.conditionalFormatting` the single definition
the grid and kanban blocks reuse by identity. `data/field.zod.ts` cannot
import `ui/view.zod.ts` (view imports field). So: the second form the
dispatch allowed, a rule schema both members share. Pinned by identity
in `field-conditional-formatting.test.ts` (`FieldSchema...element` and
`ListViewSchema...element` are both
`toBe(ConditionalFormattingRuleSchema)`).
2. **The precedent `215e66204a`** was read. Its field-rule family binds
`FIELD_RULE_BOUND_ROOTS` = `record`, `previous`, `parent` and refuses
`value` as a bare reference, so this slot could not join that loop: it
is a sibling pass beside it, with its own bound set
(`FIELD_FORMATTING_BOUND_ROOTS` = `value`, `record`) and the family's
membership-test shape.
3. **ADR-0087 entry: none owed, none added.** On `ee8751d41e`
`FieldSchema` is a `strictObject` (`field.zod.ts:1053`) and
`authorable-surface/data.json` has no `data/Field:conditionalFormatting`
row, so the key was refused before and no stored source can carry a
legacy spelling. `git grep conditionalFormatting ee8751d -- examples`
hits only the showcase field-zoo LIST VIEW. `node
scripts/check-adr-0087-registration.mjs --base origin/main`: "this PR
adds no declared-breaking changeset (1 non-breaking changeset(s) seen)",
exit 0.
4. **PR #22509 landed** (`c76edeb8c6`) and was merged in (`5b81e00352`;
one hand conflict in the `check` closure, both parameters stacked:
`servedRowSite` then `extraRoots`). The new call site passes no
`servedRowSite`, so it is fields-only by construction. The
`SERVED_ROW_SITES` docblock names it beside a field's `visibleWhen`.
## Liveness ledger
`packages/spec/liveness/field.json` row `conditionalFormatting`:
**`planned`**, drilled (`condition`, `style`) because `check:liveness`
refuses an undeclared container verdict. The in-repo reader is the
authoring gate, which validates and does not make the key do anything,
so the ledger's `live` ("has a runtime consumer") does not apply.
Cross-repo, measured: objectui main `2a48bd4` and the `.objectui-sha`
pin `f0268ad78` read `conditionalFormatting` only off list views, the
grid and kanban blocks and reports (control:
`plugin-grid/src/ObjectGrid.tsx` carries 8 hits at the pin); no
field-level read. No `authorWarn` (the `settledWhen` precedent). The
flip condition is written into the row.
## Files beyond the claim's declared surface (each one demanded by a
gate or by the shared element)
- `packages/spec/src/shared/conditional-formatting.zod.ts` and
`packages/spec/src/ui/view.zod.ts`: the shared element and the list view
mounting it.
- Re-keyed declaring position
`ui/view.zod.ts:ListViewShapeSchema.condition` to
`shared/conditional-formatting.zod.ts:ConditionalFormattingRuleSchema.condition`:
`packages/qa/dogfood/test/expression-conformance.ledger.ts` (still the
`cel-ui` row, with a note that the position now has two mounts and the
field mount has no evaluator yet) and
`packages/spec/src/shared/evaluated-slot-population.test.ts` (count
stays 34).
- `packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`: a
root `omit` row for `field.conditionalFormatting`, the `page.print`
"planned, not yet rendered" precedent, instead of a form control nothing
would paint.
- `packages/metadata-core/src/object-schema-fls-references.ts` (+ test):
`conditionalFormatting: ruleEntries`, so a rule whose condition reads a
denied field is dropped whole (a bare `style` would read as always-on)
and an emptied list is deleted.
- `packages/drivers/driver-sql/src/builtin-column-collision.ts`:
`presentation`.
-
`packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts`:
`field` 77 to 78.
- `packages/spec/llms.txt`: `check:llms-txt` counts (shared 15 to 16,
total 202 to 203).
- Generated: `authorable-surface/data.json`,
`liveness/state-counts/field.md`, `content/docs/references/**`, the
strictness-ledger counts, and three `skills/*/references/_index.md`
lines (generator-owned: `check-governed-merges.mjs --test` reports "0 of
28 path(s) hit the register after 3 generated-artifact lift(s)", NOT
governed).
## Docs
`content/docs/data-modeling/fields.mdx`: a row in the Conditional Logic
table and a "Cell formatting" subsection with the card's worked example
(an amount below zero in red), the authoring checks, the
presentation-only boundary (a deadline stays `dueLike` / `settledWhen`),
and one closing status paragraph: the console reads the rules in a later
objectui release. That paragraph is dropped on the ledger flip.
## Tests (all locked runs through `os-verify-lock`)
Every suite below ran on the merged tree at `14b85354d3`. The one later
commit, `ffce73a2ac`, changes only `packages/spec/llms.txt`:
`check:llms-txt` passes at `ffce73a2ac`, and the one spec test that
names that file (in a comment, `filter-array-declaration.test.ts`)
re-ran green there with the new pin file (2 files, 32 passed). Filter
direction: per package, `--filter PKG` itself (no consumer sweep); the
consumer radius named by the dispatch (`packages/spec`, `packages/lint`,
`packages/metadata*`, the examples) is run package by package.
| Suite | Result |
|:--|:--|
| `@objectstack/spec` `--project local` | 632 files, 18,894 passed, 1
todo |
| `@objectstack/spec` `--project repo` | 54 files, 915 passed |
| `@objectstack/lint` (whole package) | 132 files, 6,047 passed |
| `@objectstack/metadata-core` | 17 files, 388 passed |
| `@objectstack/metadata-protocol` (whole package) | 223 files passed, 3
skipped; 28,347 passed, 19 skipped |
| `@objectstack/metadata` | 58 files, 871 passed |
| `@objectstack/driver-sql` | 218 files passed, 11 skipped; 3,635
passed, 206 skipped |
| `@objectstack/dogfood` `test/expression-conformance.test.ts` | 7
passed |
| example-showcase / example-crm / example-todo | 33 / 408, 5 / 45, 7 /
238 passed |
| `objectstack validate` (showcase, crm, todo) | Validation passed, all
three |
| `typecheck`: spec (incl. `check:test-typecheck`), lint (incl.
`check:test-typecheck`), metadata-core, driver-sql, metadata-protocol,
dogfood | all exit 0 |
New pins: `packages/spec/src/data/field-conditional-formatting.test.ts`
(19: identity of the element on both members, accept, order, any field
type, control without the key, refusals at the rule with issue path plus
the message's first sentence) and
`packages/lint/src/validate-expressions.field-conditional-formatting.test.ts`
(41: `value` and `record` accepted, `today()`, a non-parsing condition
refused naming the field and the rule, bare field, unknown field, every
judged root other than `record` refused with this surface's verdict and
only that one, control on the field-rule family's own slots, `os build`
and the runtime object door under `expression-invalid`), plus one FLS
case in `object-schema-fls-references.test.ts`. `@objectstack/cli`
integration tier: not run locally, declared to CI (no spawn entry
touched).
## Ablation (each committed first, mutated through
`scripts/ablation-replace.mjs`, restored to blob == HEAD with an empty
`git diff HEAD`; subjects resolve to `src` through relative imports, so
no `dist` leg)
Expected direction for each: red. Observed: red.
| Mutation | Suite | Result |
|:--|:--|:--|
| the cell-rule `check` call passes no `value` root | lint pin file | 4
red, 37 green |
| `fieldFormattingRootIssue` returns `null` always | lint pin file | 29
red, 12 green |
| `FieldSchema.conditionalFormatting` mounts a loose `z.object` instead
of the shared element | spec pin file | 9 red, 10 green |
| FLS scrub drops only the condition (`arrayOf(block(...))`) instead of
the whole rule | metadata-core FLS file | 1 red, 51 green |
## Gates
At `ffce73a2ac`: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 116 families (the same 116 as before
the last commit); with the 51 artifact-roster commands, 166 commands
ran. `--ran`: "116 derived, 115 run, 0 NOT-MEASURED, 1 UNRUN" — the one
is `pnpm check:dual-build-cjs-loads`, NOT MEASURED by dispatch (no
whole-workspace build), left to CI. Of the 115: 114 exit 0, and `pnpm
check:platform-checklist` exit 1, which is red identically on a pristine
`origin/main` `faf6348508` worktree (see Acceptance notes). Roster: 48
exit 0; the three PR-context guards (`check-closing-target-claim`,
`check-partof-closing-keyword`, `check-single-claim-paths`) answer exit
2 without PR context; they are re-run with this PR's context after it
opens, and their verdicts are recorded in the `os-dev-report` comment on
#22228. `check:generated` (spec, after a build): all 15 artifacts
current.
## Acceptance notes
- **The list view's unknown-rule-key refusal reads differently.** One
element, one message: the `history` sentence is now true for both
members, and the `color` guidance leads with "put it in `style`" before
pointing a list view at `rowColor`. The accept set is unchanged; no test
or doc pinned the old wording (`git grep` of both sentences: 0 hits
outside the schema).
- **One expression-ledger row speaks for two mounts.** The conformance
ledger's unit is the declaring position, and the shared element is one
position. The `cel-ui` row keeps `enforced` for the list-row mount and
its note records that the field mount has no evaluator yet; the liveness
row carries the field mount's `planned` state.
- **The docs-corpus gate does not judge these conditions.**
`check:doc-formula-expressions` admits only the field-level `*When`
slots; a `condition` nested in `conditionalFormatting` is outside its
arms, so the new docs example is judged by nothing at the corpus level
(it is the same predicate the lint pins accept). Noted, not extended.
- **No traversal refusal, null-guard or `parent` gate on this slot**,
like `visibleWhen`: those are write-path gates and nothing on the write
path reads it. Whether a cell renderer hydrates `record.REFERENCE.FIELD`
is for objectui's card to declare (carrier: the objectui renderer card).
- **No `authorWarn`.** Until objectui reads the key an authored rule
styles nothing; the docs status paragraph and the changeset say so. A
warned field row would reopen the lint's field walk for one display key
(the `settledWhen` precedent).
- **`check:platform-checklist` is red on `origin/main` itself**
(`faf6348508`, a pristine worktree: `areas/attachments-storage.json`
ABSENT SYMBOL `attachment-access-hooks.ts#canEdit`, anchors 27 of floor
28). This diff touches neither file.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0e00d36 commit 3d886ee
28 files changed
Lines changed: 699 additions & 36 deletions
File tree
- .changeset
- content/docs
- data-modeling
- references
- data
- system
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- drivers/driver-sql/src
- lint/src
- metadata-core/src
- metadata-protocol/src
- qa/dogfood/test
- spec
- authorable-surface
- liveness
- state-counts
- src
- data
- system
- ui
- skills
- objectstack-ai/references
- objectstack-data/references
- objectstack-ui/references
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
391 | 391 | | |
392 | 392 | | |
393 | 393 | | |
| 394 | + | |
394 | 395 | | |
395 | 396 | | |
396 | 397 | | |
| |||
490 | 491 | | |
491 | 492 | | |
492 | 493 | | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
493 | 538 | | |
494 | 539 | | |
495 | 540 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
109 | 109 | | |
110 | 110 | | |
111 | 111 | | |
| 112 | + | |
112 | 113 | | |
113 | 114 | | |
114 | 115 | | |
| |||
241 | 242 | | |
242 | 243 | | |
243 | 244 | | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
244 | 252 | | |
245 | 253 | | |
246 | 254 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
274 | 274 | | |
275 | 275 | | |
276 | 276 | | |
| 277 | + | |
277 | 278 | | |
278 | 279 | | |
279 | 280 | | |
| |||
611 | 612 | | |
612 | 613 | | |
613 | 614 | | |
| 615 | + | |
614 | 616 | | |
615 | 617 | | |
616 | 618 | | |
| |||
0 commit comments