Repository navigation
spec(ui): declare the columns x hiddenFields x fieldOrder composition on the list view, with pins and a re-cited liveness row (ruling B) - #19598
Conversation
… on the list view `ListViewSchema`'s three field-selection keys now state the composition they already form, in the `.describe()` text that ships to authors: `columns` projects, `hiddenFields` subtracts from that projection, and `fieldOrder` orders what survives, with a surviving column absent from `fieldOrder` sorting last. No accept set moves. No key is added, removed, narrowed or widened, and the four `@objectstack/lint` list-view validators are untouched. `packages/spec/liveness/view.json`'s `/props/list/children/fieldOrder` row is re-cited at objectui @87af769e with `verifiedAt: 2026-09-21`: its first evidence path no longer exists and its second had drifted in range, so both pointers now carry symbol anchors and the relay rung is split out into `producer`. `view-field-order-composition.pin.test.ts` holds the declaration and the accept set together across the four list-view doors. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…t for the composition text Mechanical output of `pnpm --filter @objectstack/spec gen:docs` and `gen:react-blocks` after the three `.describe()` strings moved. No hand edits: every changed row is one of the three keys' descriptions, rendered where the generators already render them. `skills/objectstack-ui/references/react-blocks.md` is the `gen:react-blocks` output row in the generated-surface register, recomputable byte-exact from `packages/spec/scripts/build-react-blocks-contract.ts`, which this diff does not touch. 115 lines before and after; the package's SKILL.md total is unchanged at 309. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 136 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 7ed15cc8f5de30e9b5b0964181c14c30e93c1eae && git checkout 7ed15cc8f5de30e9b5b0964181c14c30e93c1eae
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6eaa0f4a81a0146250f083df2b9d000b75dbec61 e76041c2b472a401dd1fec67cb124de2da2fc9cd && git checkout -B drift-repro 6eaa0f4a81a0146250f083df2b9d000b75dbec61 && git merge --no-ff e76041c2b472a401dd1fec67cb124de2da2fc9cd
node scripts/docs-audit/affected-docs.mjs --json 6eaa0f4a81a0146250f083df2b9d000b75dbec61 |
…eld-order-composition
The os-regen driver deferred content/docs/references/{api/protocol,data/object,ui/view}.mdx;
os-regen-merge.sh took main's side and its step-3 commit was refused as stale. This commit is
that side regenerated from the merged tree (gen:schema, gen:docs, gen:react-blocks): the only
delta over main's side is the three list-view describe rows this branch already carried.
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
…rom the composition text Fix round for the contract review of cfc103a. - columns: an empty list declares no projection, so neither hiddenFields nor fieldOrder applies; objectui's ListView grid then derives the object's default columns. Replaces "a field omitted here is not displayed" and "nothing downstream may re-add a field this list omits". - composition docblock: the same boundary on step 1; the effectiveFields memo runs the three steps with a field-level-security read filter between steps 1 and 2 (was "exactly these three steps"); the opening sentence and the "author who wants a field gone" advice narrowed or dropped. - pin header and changeset: the describe text ships into json-schema/ and the generated docs; the TSDoc-hover leg is dropped. - pins: the columns boundary, the docblock boundary, and every door admitting columns: [] verbatim (18 -> 21 cases). - regenerated content/docs/references/{api/protocol,data/object,ui/view}.mdx. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed and posted 2026-09-23T10:59Z by the at-tier review subagent the ① Derived judgmentsSurface (8 files, +433/−36). Declared: Accept set — Governed (ran). The diff hits Declared composition vs objectui Pins are real (ran; control 21/21; every restore blob Every sentence that ships. Describes, both JSDoc blocks, the changeset, the generated rows and the pin header trace to the readings above. Both former fail bases are gone: no hover claim survives anywhere in the diff, and the Model identifiers. Swept every added diff line, the four commit messages, the changeset, the generated docs and the 21,484-byte PR body for every model-identifier spelling (product, family, tier and provider names, and the bracketed context suffix): 0, apart from the mandated attribution footer (1 line) and the mandated model-free commit trailer pair, both exempt forms AGENTS.md names; lit control on synthetic input 1/1. CI at head (42 check-runs, de-duplicated to 35 names by latest ② Semver level
③ Boundary flagsBlocking: none. Non-blocking:
Implemented-by: VERDICT: PASS |
… the removal, and a gate keeps them there (objectstack-ai#19655) Fixes objectstack-ai#18048 Clause-②: no ADR-0049 tombstones, refusal strings and doc pages across fifteen files dated a removal to the next npm major of `@objectstack/spec` — a bare **18** attached to the package name. There is no npm 18, and under ADR-0087's level ruling (Amended 2026-09-13) there will not be one as the carrier: *"A tombstone names the npm release it ships in, ⛔ never the protocol major […] a retirement shipping `minor` lands in `17.x.y`"*. A sibling repository had already hung a cleanup schedule on "the PR that pushes the spec package to its next major" — an event that will never come. This is the class's **second** appearance. Ten sites of it were corrected by hand in July with no gate, and that card closed `completed`. So the prose fix is half of this PR; the other half is `pnpm check:future-spec-major`. ## What replaces the bad number, and why it is two answers Determined **per site** from `packages/spec/CHANGELOG.md`, never pasted. The sites split cleanly, and the two halves take different spellings because different things are known about them. **Already shipped ⇒ the release that actually carries it — `17.3.0`** (published on npm; `npm view @objectstack/spec versions` ends `…17.2.0, 17.3.0, 17.4.0`). Eighteen hand sites plus four generated rows: | retirement | changeset in `CHANGELOG.md` | release | |:--|:--|:--| | `PluginHealthCheck.autoRestart` / `maxRestartAttempts` / `restartBackoff` | `b72db01` | `17.3.0` | | `HotReloadConfig.stateStrategy: 'disk'`/`'distributed'`, `distributedConfig` | `4635f3e` | `17.3.0` | | `HotReloadConfig.watchPatterns` | `ee3595c` | `17.3.0` | | form-view `options[].default` | `c459da6` | `17.3.0` | This is the version an upgrading reader greps in `CHANGELOG.md` after the tombstone error, which is exactly what the changeset-to-CHANGELOG contract exists to make findable. **Not shipped yet ⇒ the bare published major, `17`.** Eleven hand sites plus three generated rows, covering three removals that are still unreleased changesets on this branch: | retirement | pending changeset | |:--|:--| | the seven cron-typed positions (export schedules, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule`, backup / DR-test schedules) | `.changeset/cron-typed-positions-retired.md` | | `CacheWarmup.strategy: 'scheduled'` | `.changeset/17157-cache-warmup-scheduled-strategy-retired.md` | | `PluginStartupResult.startTime` / `plugin` / `health` | `.changeset/16059-startup-orchestrator-shipped-shape.md` | For these the carrier minor is **unknown at authoring time**, so any digit is a guess — the same guess that produced this defect. Bare `17` is the one spelling that is true, exists, and cannot go stale: 1. **It asserts exactly what is known.** Each of those three changesets declares `minor`, and ADR-0087 guarantees a pre-GA retirement ships `minor`, so the carrier is necessarily some `17.x.y`. The major is a fact; the minor is not. 2. **It names a version that exists.** Bare `17` is the published major. 3. **It is the house form.** 588 bare-`17` citations sit in the tree, measured with this PR's own matcher. 4. **It is what the ledger prescribes.** `docs/v17-docs-sweep.md` run 3 fixed "17 passages dating v17 removals to 18 → **17**" — bare, not a guessed minor — and that row is this class's detection fingerprint. 5. **It cannot seed a fourth recurrence.** A guessed `17.5.0` goes stale the moment the minor count moves; bare `17` does not. 6. **The neighbours already read that way.** `startup-orchestrator.zod.ts`'s own surviving `duration` → `durationMs` tombstone says bare `@objectstack/spec 17`, three lines from the three this PR rewrites. ⛔ **No protocol-major reference is altered.** `toMajor: 18`, `step18` and the `PROTOCOL_VERSION` ladder are correct and untouched — nothing here matches a number that is not directly attached to the package name, so "retired in protocol major 18" is out of reach by construction. Untouched-control: `git grep -c 'toMajor: 18'` and `git grep -c 'step18'` read the same before and after. ## The gate — `pnpm check:future-spec-major` `scripts/check-future-spec-major.mjs`, registered in the root `package.json` and wired into `lint.yml` (the gate step and the `--self-test` invocation `check:self-test-wired` requires; that gate reads 0 findings over 223 scripts). **The class is derived at runtime**, never hardcoded: "an `@objectstack/spec` version whose major is above the major in `packages/spec/package.json`". A gate hardcoding the literal goes dormant the day the tree reaches that major, and the next stale tombstone will name the one after. **It joins the joints before matching**, because a single-line matcher reads a false zero on live sites. `packages/core/src/health-monitor.ts` splits the phrase as `"… in @objectstack/spec " + '18 (objectstack-ai#12032, …'`; there are JSDoc `*` continuations and plain wraps too. **And it reads the backticked package name** — `` `@objectstack/spec` 18 `` in the same file and in `content/docs/protocol/kernel/lifecycle.mdx` — which is a second, independent way to read zero. **The gap is bounded to a sentence continuation.** Exactly one space on the same line, or a line break crossed through recognised joint syntax (a closing quote, a `+`, a JSDoc `*`, indentation). A run of two or more spaces or a tab is column alignment, never prose, so `scripts/partition-test-shards.mjs`'s `@objectstack/spec 414 files 496.4s` is not a finding. That bound has its own self-test fixture, carrying that row verbatim. **Two exemption mechanisms, and neither is a path allowlist.** Three sites cite the bad number in order to forbid it, and a gate that cannot tell a tombstone from a quotation of a bad tombstone reds on the ruling that forbids the bug. - **R1, structural.** A citation wholly inside a Markdown **double**-backtick code span is the phrase quoted as a PATTERN, which is what a fingerprint row is — so `docs/v17-docs-sweep.md`'s table row needs no ledger entry, and neither will the next fingerprint row. A **single**-backtick span is not exempt; that is the live tombstone form. - **R2, a witnessed self-invalidating ledger.** Each entry names a file, the major it excuses, a `witness` regex that must still match the quoting sentence, a pinned `covers` count, and a written `why` the failure text prints. A path allowlist says "this file may say anything"; this says "this file may say exactly this, and must still say it". Three refusals follow, each with its own battery: a **new** citation in an exempt file matches no witness and reds; a witness that matches nothing reds (editing the quotation away kills its exemption instead of leaving a hole); a `covers` count that moves reds. **The detector is excluded from its own corpus** — it documents the class in its header and holds the adversarial fixtures in its self-test, so it cites the bad form by necessity. ⛔ That would be a hole, so it comes with a compensating control in the opposite direction: the file MUST still carry at least `SELF_FIXTURE_FLOOR` citations above the published major. Gut the fixtures — the one edit the exclusion would otherwise hide — and the gate reds on itself. **The self-test is the only instrument watching the rule**, because this gate's defect class *is* its matching rule: green means the finding set is empty, weakening can only shrink that set, and the empty set is the fixed point of shrinking. 29 batteries with a pinned roster, a floor and the module-flag handshake, including the wrapped fixture, the backticked fixture, a fenced-code fixture, the column-aligned bound, a legitimate bare-17 fixture that must pass, a derived-class fixture (the same corpus goes green once the manifest says 18), and an empty-corpus refusal. ## Census, under the gate's own instrument Re-runnable at this head: ``` node scripts/check-future-spec-major.mjs --census ``` | reading | before (`34545d6c6c`) | after | |:--|--:|--:| | live sites above the published major | 39 / 17 files | **0** | | bare `18` remaining (all exempt quotations) | 39 / 17 files | 3 / 2 files | | bare `17` control | 588 / 174 files | 602 / 176 files | | dotted `17.x` control | 489 / 106 files | 511 / 113 files | The bare-17 control moving is the fix landing, not over-matching: fourteen of this PR's rewrites add a bare-`17` citation, and the dotted bucket gains the twenty-two that now say `17.3.0`. Over an unchanged file the matcher's verdict does not move. ## Verification - `pnpm --filter @objectstack/spec test` — 511 files / 14933 tests pass. `pnpm --filter @objectstack/core test` — 51 files / 1321 tests pass. Both matter here: `health-monitor.test.ts` and `hot-reload.test.ts` pin those guidance strings **by content**. - `pnpm --filter @objectstack/spec --filter @objectstack/core typecheck` — green. - `pnpm --filter @objectstack/spec check:generated` — all 15 generated artifacts up to date; `check:docs` green. The seven sites in `content/docs/references/kernel/{plugin-lifecycle-advanced,startup-orchestrator}.mdx` were **regenerated**, never hand-edited: the zod docblocks and `retiredKey()` prescriptions moved and `gen:docs` projected them. - `node scripts/pm/dispatch-gates.mjs --ran` — 151 derived families accounted for, 148 run, 3 NOT MEASURED, 0 unrun. The three are named in the report: two exit 3 (`PREREQUISITE NOT MET`, both needing a whole-tree `pnpm build`) and `check:pm-dispatch-gates`, whose own self-test is a recorded 597 s in `lint.yml` against this container's ~600 s foreground cap. - **ESLint, narrowed and the narrowing proved.** Population read from eslint's own config: 6992 tracked files with lintable extensions, none globally ignored per its `isPathIgnored`. Linted: 12 files, the count read from `--format json`, 0 errors / 0 warnings. Invariance: `eslint.config.mjs` never enables type-aware linting for any file — zero `parserOptions.project` and zero `projectService` in it, which its own header states and measured with a positive control — so every verdict depends only on that file's bytes plus the shared config. This PR changes neither the config nor a byte of the other 6980 files, so no untouched file's verdict can move. Quoted from `68ff8a1082`. ## Clause-②, measured rather than argued Every affected string is a docblock, a doc page, or a `retiredKey()` / `guidance` **message**. The key is refused before and after, so no accept/reject result moves for any input. The control that decides it: the phrase has **0 hits** across `packages/*/api-surface` and `packages/*/export-origins`, so no published declaration baseline carries these sentences and none moves. `node scripts/check-changeset-no-major.mjs --base origin/main` is green; the `--pair` C6 row is in the report. Changeset grades `@objectstack/spec` and `@objectstack/core`, both `patch`. `packages/core` is in scope although the card is anchored `domain:spec`: the triage ruling makes the gate tree-wide, and a tree-wide gate cannot be green on `main` while 11 live sites remain in `packages/core` — six of them runtime refusal strings an operator reads. ## Acceptance notes - **One bounded in-place fix, declared.** `packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts` carried a docblock reading "the watch-placeholder key retired in **18**" — the same stale number, the same defect class, but with no package name attached, so it is outside the card's stated class and outside this gate's matcher by construction. It is now `17.3.0`, matching the prescription three lines below it that this PR also rewrites. Evidence: `git diff` on that file shows the two edits adjacent; the key's removal is `ee3595c` in the `17.3.0` section of `packages/spec/CHANGELOG.md`. - **Deliberately not changed.** `startup-orchestrator.zod.ts`'s `duration` → `durationMs` tombstone says bare `@objectstack/spec 17` for a rename that shipped in `17.4.0`. Bare `17` is correct and legitimate — less specific than it could be, not wrong — so it is not in this card's class and is left alone. - **Two measurement readings are declared in the ledger, not rewritten.** `packages/spec/src/migrations/{registry.ts, entries/semantic/…}` record "controls objectstack 12966 and `@objectstack/spec` 4997 on the same corpus" and `docs/adr/0021-…md` records a `6526/6526` pass ratio. Those numbers are hit counts, not versions, and they sit exactly one space after the package name, so no whitespace rule can distinguish them from a citation. Rather than narrow the matcher — the move that produces a false zero — they carry witnessed `kind: 'measurement'` ledger entries. - **Serial note.** objectstack-ai#19598 (draft) touches `packages/spec/src/ui/view.zod.ts` at hunks well clear of the site here, which is on line 2780. Re-derived on this branch after merging `origin/main`: no textual conflict. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…tor at authoring time (objectstack-ai#19861) Fixes objectstack-ai#19751 Clause-②: no (narrowing) ## What changes `checkViewFilterRuleValueShape` (the value-shape refinement of `ViewFilterRuleSchema`, `packages/spec/src/ui/view.zod.ts`) now refuses a rule with NO `value` on every operator that takes one. Its scalar arm returned early on `value === undefined` for every operator, so `{ field: 'name', operator: 'icontains' }` parsed green, while the key's published description says every operator outside `in` / `not_in` / `between` and the four unary operators takes a scalar, and the query path refuses the lowered rule with `400 INVALID_FILTER`. - The four unary operators (`is_empty`, `is_not_empty`, `is_null`, `is_not_null`) are answered first and stay valueless, with or without a value. - `in` / `not_in` / `between` keep their own arms, which already refused an absent value. - The key stays `.optional()` on the shape; the coupling lives in the refinement, like the other arms. - The `value` `.describe()` is unchanged (it already declares this contract), so no generated reference page moves. - The code comment above the scalar arm, which named an absent value as a carve-out "the query path itself makes", now says the opposite and why. The docblock's "mirrors the query path" list and its runtime-wording section name the new arm. Refusal text, one issue at the rule's `value` path: > Filter comparand for operator "icontains" on field "name" is undefined. The rule carries no value, and "icontains" compares the field against one — write the value to compare against, or, if the rule means the field has no value, use an operator that takes none ("is_empty" / "is_not_empty" / "is_null" / "is_not_null"), which reads its direction from its name. This is refused at authoring time because the query path refuses it too (400 INVALID_FILTER). The leading sentence is the runtime's undefined-comparand sentence ("Filter comparand at PATH is undefined.") with the location named in the view vocabulary: operator and field, the same substitution the list and range arms already make. A view rule has no `where` path, and the `$` spelling in that path is not one a view author can write. The unary operator names in the tail come from the schema's own `VIEW_FILTER_VALUELESS_OPERATORS`. ## Producer reading (step 1): objectui at the pinned `.objectui-sha` `87af769e9a3ee28ace099fdd653d3ebd79fe82e2` Read with `git show SHA:PATH` from a local clone at that sha, not from a working tree. **Does the console ever save a value-taking rule with no `value`? No.** | writer | file at the pinned sha | what happens to a half-filled row | |---|---|---| | `foldFilterGroupToSpecRules`, the one fold every view-filter writer shares | `packages/app-shell/src/views/viewFilterFold.ts` | a row whose operator takes a value is dropped when `isFilterValueComplete(operator, value)` is false (`if (takesValue && isMissingValue(...)) continue`) | | `isFilterValueComplete` | `packages/components/src/custom/filter-builder.tsx` | false for `value == null` (also `''`, `[]`, a half-filled pair), so an absent value is always incomplete | | `FilterBuilderField` / `FilterBuilderWidget`: the `filter-builder` widget that `view.form.ts` names for `filter` and `page.form.ts` for `filterBy`, plus the per-tab filter editor | `packages/app-shell/src/views/metadata-admin/widgets.tsx` | calls the fold on every change; the runtime `ViewConfigPanel` hosts the same inspector (`ViewConfigPanel.tsx`, `ViewVariantInspector`) | | list toolbar | `packages/app-shell/src/views/ObjectView.tsx` | no automatic write at all (its docblock: "There is deliberately NO persistViewFilter"); explicit saves go through the fold | | drill-down "Save as view", `foldUrlFilterTriplesToSpecRules` | `packages/app-shell/src/views/ObjectDataPage.tsx` | `ViewFilterRuleSchema.safeParse` per rule, refused rules dropped; the URL triples (`drillUrlFilters.ts`, `parseUrlFilterTriples`) skip an empty param and always carry a value | One edge, stated rather than hidden: `handleViewConfigSave` (`ObjectView.tsx`) persists the config draft whole. A view whose STORED body already carries such a rule (hand-authored, or written by another tool) and is re-saved through the panel without its filter being touched now gets the refusal at save. That view already fails every query today (next table). **Does anything drop a valueless row between storage and the query? No.** | layer | file | reading | |---|---|---| | console lowering: `viewFilterRuleToNode`, behind `toFilterNode` / `mergeFilterNodes` (plugin-list `buildEffectiveFilter`, plugin-view `ObjectView`, `ObjectGrid`, `RelatedList`, `LineItemsPanel`) | objectui `packages/core/src/utils/filter-converter.ts` | a rule without `value` lowers to the 2-tuple `[field, operator]` and nothing skips it; its own comment records the runtime throwing `INVALID_FILTER` / 400 for `['name','icontains']` | | REST lookup-picker route: `lowerViewFilterRule` | this repo, `packages/rest/src/view-filter-rule-lowering.ts` | the same 2-tuple; the module forwards and never drops | | query normalizer | this repo, `packages/metadata-protocol/src/protocol.ts` | `isFilterAST`, then `parseFilterAST`, which throws | Measured on this tree's spec source (4112752): `isFilterAST(['and', ['name','equals'], ['status','equals','open']])` is true, and `parseFilterAST` of it throws `INVALID_FILTER` / 400, "Filter comparand at where.$and[0].name is undefined". One valueless rule fails the WHOLE view's query, its good rules included. So no working flow saves or executes this shape, and refusing it at save breaks nothing that works today. ## Today's behaviour for the whole class (step 2) Measured at `origin/main` 4112752 by script. The operator list is `VIEW_FILTER_OPERATORS` read at runtime; the unary set was derived by behaviour from the schema's own scalar arm (an array is refused on every non-list, non-range operator outside the private valueless set). | operators | `ViewFilterRuleSchema`, `value` omitted, before this change | `parseFilterAST([field, op])` | |---|---|---| | `equals`, `not_equals`, `contains`, `not_contains`, `icontains`, `starts_with`, `ends_with`, `greater_than`, `less_than`, `greater_than_or_equal`, `less_than_or_equal`, `before`, `after` (13) | **ACCEPT** | throws `INVALID_FILTER` / 400, "Filter comparand at where.name (or where.name.$op) is undefined" | | `in`, `not_in` | refused by the list arm | throws, "requires an ARRAY of values" | | `between` | refused by the range arm | throws, "requires a [min, max] value array" | | `is_empty`, `is_not_empty`, `is_null`, `is_not_null` | accept | `{ "$null": true }` / `{ "$null": false }` | After this change the 13 are refused. The other rows are unchanged. ## ADR-0087 reading (step 4) - This narrows a published accept set. The repo's rule for that during the launch window is in the header of `scripts/check-changeset-no-major.mjs`: the level does not carry breaking-ness, and "the mandatory information carriers for breaking-ness in the meantime are the **BREAKING** banner the author writes in the changeset body and the ADR-0087 migration-ledger disposition". `scripts/check-adr-0087-registration.mjs` then requires a disposition on the declared-breaking changeset. - The disposition is `registered`, not `not-required`. The author has a hand prescription (write the value, switch to a unary operator, or delete an unfinished row), and `no-migration-prescription` is refused for a body that carries one. None of the other categories fits: the package publishes, no existing entry covers absence, and the surface is a schema, not a runtime interface or a type surface. - Precedents: `view-filter-rule-scalar-operator-array-refused` (the sibling arm of this same check) and `filter-preset-ordering-comparand-refused` (a shape that never executed usefully) both registered a semantic entry under protocol major 18. - Added: `packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts`. `packages/spec/src/migrations/registry.ts` was regenerated by `pnpm --filter @objectstack/spec gen:migration-registry` and not hand-edited; `check:migration-registry` is green. No D2 conversion: there is no value to infer. - `check-adr-0087-registration --base origin/main` reads the changeset as `[BREAKING+clause-②-narrowing] registered view-filter-rule-absent-value-refused (new here)`. - `spec-changes.json` and `docs/protocol-upgrade-guide.md` did not move. The protocol-18 step stays inert until the protocol major reaches 18, and `check:spec-changes` / `check:upgrade-guide` are green without regeneration. ## Changeset (step 7) `.changeset/19751-view-filter-rule-absent-value-refused.md`, `minor` on `@objectstack/spec`. `files[]` ships `dist` and `src/**/*.zod.ts`, and both carry the refinement. Its summary is the user-visible change: a stored view filter rule with no value on a value-taking operator is now refused at save instead of failing every query. It carries the BREAKING banner, a FROM/TO block, `Clause-②: no (narrowing)` and the registered disposition marker. One sentence names that it reverses the carve-out the still-pending objectstack-ai#19514 changeset records (an omitted value "still parses", an absent comparand "is left unjudged"), so the two entries do not contradict each other in the compiled CHANGELOG. The objectstack-ai#19514 file itself is not edited. Level: `minor`. An accept-set narrowing declared `(narrowing)` is BREAKING (AGENTS.md, Post-Task Checklist step 3), and during the launch window a breaking change ships as `minor`: the header of `scripts/check-changeset-no-major.mjs` says "During the launch window we ship breaking changes as `minor`", and that the BREAKING banner and the ADR-0087 disposition carry the break, not the level. Both precedents above shipped `minor` with the same banner. The first round graded this `patch`; the at-tier contract review (record 5808364674) failed that, and the patch-round commit ab0104d changes the frontmatter to `minor` and rewrites the banner sentence to state the convention ("Shipped as `minor` under the repo's launch-window convention for accept-set narrowings"). That commit moves no package file. The PR's `Clause-②: no (narrowing)` line is the claim's, copied verbatim, and matches the changeset's line. With the arm present, the level axis of `check-changeset-no-major.mjs` judges the level instead of standing down. Measured offline with `--event` on this body, it refuses the first round's `patch` head 22a14a1 (exit 1) and passes `minor` at ab0104d (exit 0). ## Fixtures, examples and pins (step 5) - An AST scan of every tracked `.ts` / `.tsx` / `.mts` / `.js` / `.mjs` / `.json` outside `content/docs/references/` (1,739 files mention `operator`) found 311 object literals with `field` plus a string-literal value-taking operator (aliases folded). 20 of them have no `value` key, and none is a view filter rule in a shipped example or seed: - 7 are QA assertions (`expectedValue`, a different schema) in `examples/app-showcase/qa/platform-smoke.test.json`; - 5 are QA assertions in `packages/core/src/qa/runner.test.ts` and `packages/spec/src/qa/testing.test.ts`; - 1 is a skill trigger condition in `packages/spec/src/ai/skill-trigger-condition-value-shape.test.ts`; - 4 are a structural walk with no schema in `packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts`; - 3 are in `view-filter-rule-value-shape.test.ts`. Markdown (`.md` / `.mdx`) has no match. No fixture was an authoring mistake, so no fixture was edited. - Pins that pinned the removed carve-out and moved with it: - `packages/spec/src/ui/view-filter-rule-value-shape.test.ts`: `equals + omitted` and `greater_than + omitted`, from accepted to refused. - `packages/spec/src/data/filter-icontains-parse-door.test.ts`: "ABSENCE is left unjudged" now asserts that absence is refused once, in the absent-value arm's words and never in the conformance table's.⚠️ This file is outside the claim's declared file surface. It is a view-filter-rule test that lives in `src/data/`, not beside `view.zod.ts`, and it had to move with the carve-out it pinned. - Carriers named in the docblock (`ListView.filter`, a tab filter, `Page.filterBy`, a related-list filter, a lookup picker filter, plus `ObjectGridProps.defaultFilters`) are all `z.array(ViewFilterRuleSchema)`. The full spec suite is green, and a new pin drives the refusal through `ListView.filter` at `filter.1.value`. ## Tests - New pins, with operator lists derived at runtime: value-taking is `VIEW_FILTER_OPERATORS` minus the four valueless operators. The valueless set is module-private in `view.zod.ts` and deliberately not exported, so the test reuses the file's existing transcription, and a new two-way sweep holds that transcription equal to the private set by behaviour: over every operator, an absent value is accepted exactly when the operator is valueless. - `vitest run --project local` on `view-filter-rule-value-shape.test.ts` and `filter-icontains-parse-door.test.ts`: 104 passed. - Firing control at 325052f, through `scripts/ablation-replace.mjs`: the anchor `if (value === undefined) {` was replaced by `if (value === undefined) return;` followed by `if (false) {`, which is the base behaviour (an absent value returns before any issue). The anchor went 1 to 0 and the blob b6b2f44 to 8c2839db. Result: **11 new pins red, 57 green**. After the restore, the blob equals HEAD and `git diff HEAD` is empty. A first attempt was refused by the tool before anything ran, because its replacement contained the anchor; nothing was measured on that attempt. - Full spec `local` project at 22a14a1: 522 files passed, 15,420 tests passed. One file skipped by its own stale-dist condition (`scripts/root-entry-type-nameability.pin.test.ts`); after a rebuild at the same head it ran with `OS_EXPECT_ROOT_NAMEABILITY=1`: 2 passed. - Spec `repo` project at 22a14a1: 34 files, 587 tests passed. - `pnpm --filter @objectstack/spec typecheck` at 22a14a1: exit 0 (tsc, scripts typecheck, `check:test-typecheck` OK). ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at 22a14a1 derived 86 commands. All were run and reconciled with `--ran`: 84 exit 0, 2 NOT MEASURED, 0 unrun. The two NOT MEASURED are `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3 PREREQUISITE NOT MET because they need the whole-workspace build. They are left to CI. `check:generated` is 15/15 up to date after a fresh build at that head. Generated files that moved: `packages/spec/src/migrations/registry.ts` only, via `gen:migration-registry`. Patch round at ab0104d: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 86 commands from a tree 53 commits behind origin/main 2c1011b. origin/main's selector over the same six paths adds one family, `check:migration-registry`, which was run too (exit 0). The `--ran` reconciliation reads 84 run, all exit 0, 2 NOT MEASURED (`check:dual-build-cjs-loads`, `check:type-check-debt`: exit 3 PREREQUISITE NOT MET, they need the whole-workspace build, left to CI), and 0 unrun. `check-changeset-no-major --base origin/main` prints "This diff introduces no `major` bump." `check-adr-0087-registration --base origin/main` reads `[BREAKING+clause-②-narrowing] registered view-filter-rule-absent-value-refused`. CI at ab0104d: 35 check runs, 32 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke), 0 failure. ## Scope held - In `view.zod.ts`, only `checkViewFilterRuleValueShape` and its docblock changed. The `ViewFilterRuleSchema` block is untouched: its JSDoc and `.describe()` are still true. None of PR objectstack-ai#19809's regions is touched. - `FILTER_TEXT_CASES` gains no row. There are no objectui or runtime (`parseFilterAST`) edits. - `origin/main` has moved 8 commits past the branch point: objectstack-ai#19598 touches the `ListView` shape in `view.zod.ts`, and objectstack-ai#19657 touches `registry.ts`. A driver-less `merge-tree` of HEAD onto `origin/main` 8cbc3c0 is clean. Main is not merged in. ## Acceptance notes - The sibling entry `view-filter-rule-scalar-operator-array-refused` says, in its replacement prose, "An omitted value is still an omitted value". That was true of its own arm; after this change an omitted value on a scalar operator is refused. Both entries sit in the uncut protocol-18 step. The new entry's leading comment names the reversal, and the sibling's text was left as it is (it is outside this card's file surface). - `checkViewFilterRuleTextComparand`'s docblock, carve-out 1, says an omitted comparand "is left to whatever judges absence". That stays true: the shape arm now judges it. Not edited. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #15184
Clause-②: no
Fix round — contract review
5791391832(FAIL oncfc103a20a), now ate76041c2b4Revised by
domain:specseat 2, sessionsession_01UDXER3sdqfeVYpEWZs5mZx. Both fail bases were re-derived before fixing and both reproduced; the folded-in note did too.packages/specat the merged tree: quick-info onfieldOrdercarries 0 documentation characters atdefineView, atListViewSchema.parseand on theListViewtype member, and no hover at any of those sites carries a.describe()lead string. The instrument is lit:columnsandhiddenFieldsshow their JSDoc (447 and 1859 characters). The built declaration files (94dist/*.d.ts/*.d.mts) contain 0 of the three describe lead strings and 0 docblock phrases (Field Visibility,orders what survives,declares no projection), against 70 lines namingfieldOrder. Thejson-schema/and generated-docs legs stay; both were re-read (json-schema/ui/ListView.jsoncarries the describe text, andjson-schemais infiles[]).columns: []boundary. "A field omitted here is not displayed" and "Nothing downstream may re-add a field this list omits" are gone. In their place:hiddenFieldsandfieldOrdercannot add a fieldcolumnsomits, and an emptycolumnsdeclares no projection, so neither of them applies, and which columns show is left to the renderer. The boundary is pinned in the declaration half, twice (thecolumnsdescription and the docblock's projection step), and the door half pins that all four doors admitcolumns: []verbatim. ⛔[]is not refused at the door; that would be a narrowing, and it is not this card's call.effectiveFieldsmemo runs the three steps in this order, with one more between steps 1 and 2: it drops the columns field-level security denies the current user read on.Two sentences not named by the review were narrowed while fixing base 2, because they carry the same over-reach. The docblock's opening "three keys … decide which fields a list view shows" became "together build one field list": at the pin the kanban branch takes
cardFieldsahead of the composed list (ListView.tsx:2977), and the gallery branch passes its owntitleField, defaulting to'name'(:3058). And "an author who wants a field gone editscolumns…" was deleted, since emptyingcolumnsbrings the object's default columns back in theListViewgrid.The boundary, measured at objectui
.objectui-sha87af769e9a3e(git showfrom the local objectui clone):packages/plugin-list/src/ListView.tsx:2828-2831: thehasAuthoredColumnsmemo isArray.isArray(schema.columns) && schema.columns.length > 0. The comment at:2795says "The author declared none (columnsabsent, or[])".:2880-2882, the grid branch: an unauthored projection passes{ fields: undefined, columns: undefined }.packages/plugin-grid/src/ObjectGrid.tsx:3228: "Default columns priority (when schema doesn't specify columns)". In that file 0 lines namehiddenFieldsand 0 lines namefieldOrder, against 71 lines namingcolumns.ListView.tsx,allFields) is built fromschema.columnstoo, so with[]it offers nothing to hide.Ruling B is what this PR executes — the card body is superseded
The card body executes the 2026-09-04 ruling "retire at the spec" and declares
Clause-②: yes. That direction is dead. Ruling B (comment 5635654931, director seat summon #22, maintainer decision batch #115, verbatim 「同意」, 2026-09-11) supersedes it, verbatim:Nothing is retired, tombstoned or deprecated here. No
retiredKey(), no ADR-0087 entry, nopackages/lintvalidator change.1. The declaration —
packages/spec/src/ui/view.zod.tsThe three keys now state their own role in the composition, in the
.describe()text that ships (intojson-schema/and into the generatedcontent/docs/references/**), plus one docblock above the block that states the application order once:columnsis the projection: the candidate set AND the baseline order.hiddenFieldsandfieldOrdercannot add a field omitted here. An emptycolumnsdeclares no projection, so neither of them applies, and which columns show is left to the renderer (objectui'sListViewgrid derives the object's default columns).hiddenFieldssubtracts from that projection, before any ordering runs. A namecolumnsnever projected subtracts nothing.fieldOrderorders what survives and never adds a field. A surviving column absent fromfieldOrdersorts last, after every listed one, keeping itscolumns-relative order; a name listed there that did not survive orders nothing.This is a declaration of the ORDER OF APPLICATION, not a precedence rule between rival spellings —
columnsandfieldOrdernever contradict each other, because one selects and the other sorts. That is why the retirement premise was false.The accept set does not move (
Clause-②: no): the schema declaration is unchanged apart from the description strings —No key is added, removed, narrowed or widened, and no parse verdict changes anywhere. Re-measured this round, base
origin/main6eaa0f4a81against heade76041c2b4: 22 inputs went through the four doors (ListViewSchema,ObjectListViewSchema,defineView, the registeredviewschema). The inputs includecolumns: []alone,[]with both other keys, all-empty arrays, a kanban with[], missingcolumns, wrong types, an unknown key and a case variant. Both trees gave 48 accept / 40 reject. The probe output is byte-identical (sha2566e4f279566c5c0e0…at both) in verdicts, parsed values and issue codes.ListViewSchema.shapeis identical: 50 keys,columnsrequired, the other two optional. Lit control in the same run: the descriptions differ for exactlycolumns,hiddenFieldsandfieldOrder, and not forrowColororrowActions.2. The pins —
packages/spec/src/ui/view-field-order-composition.pin.test.ts(21 cases)Two halves, deliberately independent:
columnsboundary: once in thecolumnsdescription, once on the docblock's projection step.ListViewSchema,ObjectListViewSchema,defineViewand the registeredviewmetadata schema — and asserts the arrays come back VERBATIM, with no unrecognized-key report. Three more cases pin that the spec DECLARES the composition and does not PERFORM it: no cross-validation offieldOrderorhiddenFieldsagainstcolumns(reference integrity is@objectstack/lint's job, against the object's real field map), andcolumnscomes back whole. One more pins that every door admitscolumns: []verbatim, which makes the boundary the declaration states reachable. A later ruling that refuses[]retires that case and the boundary sentence together.The pin asserts nothing about how a renderer sorts. That belongs to objectui and is carried by the ledger row below.
Ablation — seven legs at
e76041c2b4, each mutation proven on disk and each restore proven byte-identicalEvery leg ran through
scripts/ablation-replace.mjs: literal anchor, hit count declared, blob hash before and after, andgit diff HEADempty on restore. The legs ran against the committed tree, under a driver carrying its own EXIT/INT/TERM restore. Control: 21 of 21 green.fieldOrder's.describe()reverted to its pre-ruling one-linerfieldOrderkey DELETED from the shape (the superseded retirement)columnsdescriptioncolumnsboundary casecolumnsarrays given.min(1), so the doors refuse[]hiddenFieldsandfieldOrdercannot add a field omitted here" removedRestore evidence, identical on every leg: blob after restore
5a0b5cf9d298equals the blob at HEAD,git diff HEADis empty, andgit statusis clean. Leg B's first attempt was a no-op: plant mode with an empty replacement, which the tool refused before running any test. It was re-run in--deletemode, and that run is the reading above.3. The ledger —
packages/spec/liveness/view.json,/props/list/children/fieldOrderRe-measured against the pin THIS tree carries,
.objectui-sha=87af769e9a3e(⛔ not objectui HEAD, and ⛔ not ruling B's own 2026-09-11 coordinates, which had drifted again).verifiedAt: 2026-09-21.evidencenow cites the applying reader:objectui packages/plugin-list/src/ListView.tsx#effectiveFields:2750-2784— it takesschema.columnsas the candidate set, subtracts the hidden-field set at:2766-2771, then at:2774-2781builds an order map fromschema.fieldOrderand sorts the survivors by it. A survivor the map does not name scoresInfinity, which is the mechanism behind "sorts last".:2784listsschema.fieldOrderas a memo dependency.produceris new, and it is the half a consumer pointer alone cannot prove:objectui packages/plugin-view/src/ObjectView.tsx#renderContent:2284relays a named view'sfieldOrderonto the list node that memo reads. Without a producer the read could be permanentlyundefined— theSeed.envshape the ledger README records.git ls-tree -rat objectui@87af769ereturns 0 files underpackages/react/src/spec-bridge/, whilepackages/react/itself returns 160 — the lit control, so the zero belongs to the directory and not to the instrument. Its second rotted IN RANGE::1499-1500at@11c1e71e,~:2424-2434when ruling B was written,:2774-2781today. Both pointers now carry symbol anchors, which is the form that moves with the consumer.noteas objectui's to close and explicitly NOT a defect in this key: the OTHER relay,objectui packages/app-shell/src/views/ObjectView.tsx, carrieshiddenFieldsat:2480but still recordsfieldOrderas a declared absence (objectui#7516) in its relay-rung census. The plugin-view rung cited underproduceris the live one.packages/spec/liveness/state-counts.mdandliveness/README.mdwere DECLARED but not cleared (held at claim time by PR #19594, merged since). Neither is in this diff. On the merged tree,check:livenessreportsstate-counts.mdcurrent: the same 40 rows. The row's status stayslive, so no count moved.4. Changeset —
patch, as ruling B fixed@objectstack/specshipssrc/**/*.zod.tsandliveness/in itsfiles[], so both edits publish.Clause-②: nopluspatchis the declared pair; no arm of the(widening)/(narrowing)pair applies, because the accept set does not move.5. Generated artifacts
content/docs/references/{ui/view,api/protocol,data/object}.mdxandskills/objectstack-ui/references/react-blocks.mdare the mechanical output ofgen:docsandgen:react-blocks. Every changed row is one of the three descriptions rendered where the generators already render it — no hand edits, and nothing unrelated moved.This round merged
origin/main(6eaa0f4a81) throughscripts/pm/os-regen-merge.sh. The driver deferred the threecontent/docs/references/**files and step 2 took main's side. The step-3 commit was then refused as stale, so that commit (47bc43df13) carries the three files regenerated on the merged tree (gen:schema,gen:docs,gen:react-blocks). Its only delta over main's side is the three describe rows this branch already carried: 27 rows in 9 tables. Nothing was text-merged. The fix commit regenerated them again.react-blocks.mddid not move this round, because itscolumnscell is truncated before the changed sentence.About the one governed path.
skills/objectstack-ui/references/react-blocks.mdis thespec-react-blocksrow ofGENERATED_SURFACE_EXCEPTIONS: recomputable byte-exact bypnpm --filter @objectstack/spec gen:react-blocks, whose trusted generator tree ispackages/spec/scripts/— which this diff does not touch, so the #11084 co-edit fence does not trip. Size readings, since a published skill package is in the diff: the changed file is 115 lines before and 115 after (one row rewritten, net 0), and the package's SKILL.md total is 309 before and 309 after. No skill prose was authored here.Verification — this round, at
e76041c2b4(merged tree)All exit codes were captured BEFORE any pipe. Heavy runs went through
scripts/pm/os-verify-lock.sh, and each printedVERDICT command-exit 0.pnpm --filter @objectstack/spec build(with DTS)check-dts-emitted34/34pnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typechecktsconfig.test.jsonprogram (1 of 491 test files) with 0 debt entriespnpm --filter @objectstack/spec check:generatedcheck:liveness·check:objectui-pin-citations·check:docs·check:react-blocks·check:nul-bytesnode scripts/pm/dispatch-gates.mjs --rannode scripts/pm/check-governed-merges.mjs --pr 19598(installed tree)react-blocks.mdis lifted as a pure regeneration; 8 paths, +433/-36The 2 NOT MEASURED are
pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debt. Both exit 3 (PREREQUISITE NOT MET) and both need a whole-monorepodist. ⛔ Neither is a pass and neither is a finding; CI builds the closure before running them. Thirteen gates first answered on a missingdist: twelve with exit 3, andcheck:generatedwith exit 1 through itscheck:api-surfacemember. They were cleared by the spec DTS build and by a targetedturbo run buildof@objectstack/lint,@objectstack/formula,@objectstack/client-reactand@objectstack/objectql, then re-run green.Lint was narrowed, not run repo-wide. Three readings back the narrowing. ① Population: all eight changed paths were handed to eslint. Its own config lints the two
.tsfiles (print-configresolves rules for both), and it supplies no configuration for the other six (.md,.mdx,.json): each answers "File ignored because no matching configuration was supplied". ② Count: the--format jsonoutput lists 8 files. The 2 linted ones carry 0 errors and 0 warnings; the other 6 carry only that one ignore warning each. ③ Invariance: noparserOptions.projectand noprojectServiceare set, so linting is not type-aware and this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI'sLint & Repo Gates.check:objectui-pin-citationsis green (46 asserting citations match87af769e9on the merged tree), but it scanspackages/spec/src/**only. The ledger's@87af769ecitation lives inliveness/view.json, outside that scan, andcheck:livenessattributes cross-repo paths without resolving them. The objectui coordinates in this body and in the row'snotetherefore rest on measurements taken by hand at the pin, with the lit controls stated.Acceptance notes
hiddenFieldsledger row has the same rotted citation./props/list/children/hiddenFieldsstill citesobjectui packages/react/src/spec-bridge/bridges/list-view.ts:123(the directory that greps to zero above) andListView.tsx:763/:1491-1494/:1509, all measured at@11c1e71eand all drifted at the current pin — the applying lines are:1465-1466and:2766-2771. It is left untouched deliberately: ruling B names ONE row, and widening past an explicit ruling is not this PR's call. It is reported to the seat for filing rather than fixed here. Whoever next re-cites aview.jsonlist row will touch this file.object-viewrenderer's grid path applies neitherhiddenFieldsnorfieldOrder. At objectui87af769e,packages/plugin-view/src/ObjectView.tsxrelays both keys only inside the host-compositionrenderListViewbranch (:2268,:2284). Without a host, a grid view rendersObjectGriddirectly (:2335-2344) fromgridSchema(:1909), and neither that memo norObjectGrid.tsxreads either key. On that path a named grid view withcolumns: []also sendsfields: []:viewColumnFieldNamesis presence-preserving, andschemaFieldsis then shown as-is (ObjectGrid.tsx:3245-3246). This is objectui's to close. It changes no sentence here, because the boundary text namesListView.维护者速读(草稿)
改了什么 ——
ListViewSchema上columns/hiddenFields/fieldOrder三个键的说明文字,现在把它们本来就构成的组合关系写进契约:columns投影,hiddenFields做减法,fieldOrder给剩下的排序(没被列进fieldOrder的排在最后)。配一份把这条组合钉在四个 schema 入口上的 pin 测试,外加liveness/view.json里fieldOrder那一行的证据重测。复核轮补上两处:说明文字不再声称会出现在编辑器悬停提示里(实测不会);columns为空数组时不算投影,另两个键都不生效,由渲染器决定显示哪些列(objectui 的ListView表格改用对象的默认列),这条边界也钉进了 pin。为什么改 —— 2026-09-04 的裁决要把
fieldOrder退役,前提是它和columns是同一件事的两种拼法、且没有契约规定谁赢。这个前提经实测不成立:两者根本不冲突,一个选字段一个排顺序,objectui 一直就是三步连着做的。2026-09-11 决策批次 #115 的裁决 B 改为保留该键,并要求把这条组合写进契约 —— 本 PR 就是执行它。风险与代价(含回滚) —— 受理集合不动,没有任何键被加、删、收紧或放宽,解析结果零变化,
packages/lint的四个校验器没碰。代价只有三处说明文字变长,以及随之重新生成的参考文档与 react-blocks 契约。回滚 = 直接 revert 本 PR,没有数据迁移、没有 tombstone、没有下游需要跟进的动作。席位意见 —— 本轮修正已交付;达档复核按维护者「契约复审不要启动了,交给下一任」交给下一任席位,复核通过前不入队。
你要做的 —— 本 PR 唯一触到的受管路径是
skills/objectstack-ui/references/react-blocks.md,它是gen:react-blocks的产物行,队列腿可按字节重算认证、无需您点头。若认证因故没生效,则需要您对该一行(115 行文件中被重写的 1 行、净增 0 行)给一次批准;除此之外无需您做任何事。Generated by Claude Code