Skip to content

feat(spec)!: retire the list view's filterableFields — declared as the userFilters.fields shorthand, read as a Filter-panel whitelist; no per-view whitelist replaces it - #24721

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24640-retire-filterable-fields
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24640-retire-filterable-fields

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24640

Clause-②: yes (narrowing)

What

filterableFields retires from the list-view shape under ADR-0049 enforce-or-remove, on the maintainer's ruling C (record 6278975049, verbatim 「C」), pulled into 18.0 (record 6279033298, verbatim 「提前」). The key carried two meanings. The spec described it as the legacy shorthand for userFilters.fields (the quick-filter bar), and the Studio view form offered it as "Field names available for filtering". Its one renderer, objectui's ListView (the filterFields memo), read it as the Filter panel's field whitelist. The ruling is that the platform carries no per-view Filter-panel whitelist, so no key replaces it.

  • Tombstone. filterableFields: retiredKey(LIST_VIEW_FILTERABLE_FIELDS_RETIRED) sits in ListViewShapeSchema, beside searchableFields. Every door built from the shape refuses the key with the prescription: tsc types it never, and the parse raises the prescription instead of a bare unknown-key report. The prescription says: delete the key, no per-view Filter-panel whitelist replaces it; for a curated end-user filter bar declare userFilters: { element: 'dropdown', fields: [{ field: 'FIELD_NAME' }] }; the Filter panel offers every field the user may read (field read security decides, and a field the object marks hidden: true is not offered). It closes with the house os migrate meta --from 17 sentence.
  • Studio form. The filterableFields input leaves view.form.ts, with a comment in its place. The four extracted form-label bundles are regenerated (pure deletion).
  • ADR-0087. The D2 conversion view-list-filterable-fields-removed (protocol 18, retiredFromLoadPath: true, retiredAfter: '17.7.0') drops the key from every list payload in stack.views[], in all three persisted spellings. It never rewrites it to userFilters.fields. The D3 entry is list-view-filterable-fields-retired. RETIRED_KEYS_BY_MAJOR[18] gains ui/ListView:filterableFields and ui/ObjectListView:filterableFields. The step-18 rationale gains one fragment.
  • Ledger. The view.json row /props/list/children/filterableFields flips live to dead. It keeps the tombstone discipline (verifiedAt 2026-10-19, evidenceScope: cross-repo) and carries a REMOVED note naming the one reader the ruling makes unreachable. state-counts/view.md is regenerated: live 79 to 78, dead 11 to 12.
  • Docs. The os:check example on content/docs/concepts/architecture.mdx stops teaching the key. It declares a userFilters dropdown on industry instead, because the example meant a user-facing filter. The reference pages are regenerated.
  • Changeset. .changeset/24640-list-view-filterable-fields-retired.md: @objectstack/spec minor, with a BREAKING banner, a FROM to TO table, the one-line fix and a stored-row paragraph. It carries the Clause-② line and the ADR-0087 marker registered view-list-filterable-fields-removed, list-view-filterable-fields-retired. Release arithmetic, measured: .changeset/pre.json is { "mode": "exit", "tag": "next" } and the spec label is 18.0.0-next.5. A minor increment of that prerelease is 18.0.0, so the next version pass publishes this in 18.0.0.

Premises, each with its reading (base bfc1539fe0; re-read after merging origin/main 1c1144fcd8)

P1 holds. One declaration exists: packages/spec/src/ui/view.zod.ts (was :2844) on ListViewShapeSchema. ObjectListViewSchema is ListViewShapeSchema.omit({ userFilters }).extend(...), so one tombstone covers both. Both authorable-surface/ui.json rows (:757, :944) now read [RETIRED]. Inside packages/spec/src, git grep -c filterableFields at base hit 4 files: the declaration, the form row and two pins. No schema outside the list-view family declares the key. The other carriers are the same family. The flattened overlay arms are built from the shape. ListViewPageProps (a page list-view node, ComponentPropsMap['list-view']) spreads ListViewSchema.shape. The react ListView block's schema is ListViewSchema, and its curated dataProps never listed the key (see the Acceptance notes for the react-page boundary).

P2 holds, and the census found more readers than the card listed.

  • Zero authors, each with a lit control on the same instrument at base. git grep -c for filterableFields, then searchableFields (control), then userFilters (control):

    corpus filterableFields searchableFields userFilters
    examples/** 0 2 7
    packages/apps/** 0 0 0
    packages/qa/** 0 2 0
    skills/** 0 20 12
    packages/platform-objects/** 4 12 21

    The 4 platform-objects hits are the four generated translation bundles, regenerated here.

  • packages/create-objectstack reads 0 for all three keys. Its templates author no list view at all, so that zero is uninformative beyond that fact.

  • Beyond the card's list, these mention the key without authoring it:

    • packages/lint: the list-view field-ref rule's role and severity maps and its fixtures, REACT_FIELD_SPECS.ListView, the field-consumer vocabulary, and two rule-explanations sentences;
    • packages/metadata-core: the ADR-0106 field-security scrub's LIST_VIEW_NAME_LISTS;
    • packages/metadata-protocol: computeViewReferenceDiagnostics, a raw-input read;
    • packages/objectql: that read's fixtures.
  • None of those is an author. All are outside this card's file surface and are left in place (Acceptance notes). The tree-scoped pin carries the four files that spell it as an object key as self-expiring RESIDUE.

P3 holds. The mechanism is the one the closest 18.x list-view retirement used (view-list-tabs-removed), with the strip shape of 17.x view-list-passthrough-keys-removed.

  • Entries. One retired-keys/18.ui__ListView__filterableFields.ts and one 18.ui__ObjectListView__filterableFields.ts, registered per key as gate (b) reads them. One D3 semantic/18.list-view-filterable-fields-retired.ts, per family.
  • Conversion order. The conversion is inserted into MAJOR_18_CONVERSIONS where its identifier sorts, with order: 71. It is defined directly above viewListTabsRemoved. scripts/conversions-major18-merge.test.ts is green.
  • Rationale. The STEP18_RATIONALE fragment sorts by id, with order: 105. scripts/step18-rationale-merge.test.ts is green.
  • retiredAfter: '17.7.0' is the newest published stable. retired-after.census.json is untouched: it is generated from published tarballs, and its last census release is 17.4.0. Under the label 18.0.0-next.5, retired-after.census.test.ts admits [17.4.0, 18.0.0).
  • The pin's JOIN row. retired-key-removal-version.pin.test.ts requires a JOIN row for every retired entry at the newest stable, so one row is added.
  • Disposition. check-adr-0087-registration passes with registered (both ids new here). no-migration-prescription would refuse itself, because the changeset carries a FROM to TO table.
  • Not a semantic mapping. The machinery asked for a conversion. It is a drop, so this premise did not fail.

P4 holds. Every generated artifact moved by its own tool:

  • pnpm --filter @objectstack/spec build wrote authorable-surface/ui.json (2 rows to [RETIRED]);
  • gen:docs wrote 3 reference pages;
  • gen:liveness-counts wrote state-counts/view.md;
  • node scripts/check-i18n-bundles.mjs --write wrote the 4 platform-objects bundles;
  • gen:migration-registry wrote the gitignored registry.

Hand-written, by design: the ledger row (measurement prose, no generator) and the registry.ts.template fragment. authorable-surface.base.json is untouched. After the merge of origin/main, check:generated reads "All 14 generated artifacts are up to date".

Mechanism assumptions G1 to G5, measured

  • G1. ui/view-list-filterable-fields-retirement.test.ts pins the refusal at eight doors:

    • ListViewSchema;
    • ObjectListViewSchema;
    • ViewSchema list;
    • ViewSchema listViews.*;
    • a ViewItemSchema record's config;
    • the view write door's flattened overlay;
    • ObjectSchema listViews.*;
    • a page list-view node's properties.

    Each pin checks issue code invalid_type, the path naming the key, and the prescription. Each door has two lit controls: the same body without the key parses, and the same body with userFilters: { element: 'dropdown', fields: [...] } plus searchableFields parses. The tsc channel is pinned too (@ts-expect-error compiles under the test-layer typecheck), and the parse throws the same prescription.

  • G2. The form row is gone, pinned by a walk of viewForm with anti-vacuity neighbours. The bundles are regenerated and check:i18n passes.

  • G3. architecture.mdx is rewritten. check:skill-examples reports "262 prose examples type-check".

  • G4. As above. The derived changeset gates were run on the final head (Gates).

  • G5. Every consumer suite is green (Tests). No fixture in those packages authors the key through a parse, so none needed re-truing. The lint and objectql fixtures that spell it feed raw-input walks and still pass.

    • One out-of-lane pin had to move. It is a count, not a fixture: packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts, where the metadata-form catalog drops 674 to 673. The removed input took its label out of all three locales.
    • That edit is a cross-lane rider, listed for the seat to declare.

Ablation (the tombstone is what shuts every door)

The fix was committed first. Through scripts/ablation-replace.mjs, in wrap mode under its own restore trap plus an outer trap on the absolute path, the tombstone line was swapped back to the pre-retirement declaration filterableFields: z.array(z.string()).optional().

  • Mutation on disk: anchor count 1 to 0 and replacement count 0 to 1. The blob moved from 1e5e34e05ab8 to d38c1ae00383, and an in-command grep -c read 1 and 0.
  • Run: vitest run of the new pin plus retired-key-removal-version.pin.test.ts went red, the predicted direction. 13 failed and 71 passed.
  • What failed:
    • all 8 door refusals;
    • the empty-array refusal;
    • the prescription content;
    • the tsc-channel parse;
    • the object-listViews boundary, whose object now parses;
    • the removal-version JOIN row.
  • What held: every control and boundary acceptance, and every D2 conversion pin. Those pins are independent of the shape.
  • Restore: the blob after restore equals HEAD (1e5e34e05ab8), and git diff HEAD is empty. The tests import src by relative path, so no dist leg applies.

Tests (consumer packages --maxWorkers=2, under os-verify-lock.sh)

  • @objectstack/spec local: 702 files, 21352 passed (1 todo). Run at c0c1a8ad33 and again on the merged head bb71914fc9.
  • @objectstack/spec repo project: 64 of 71 files, 1039 passed, on bb71914fc9. The new pin is 1 of the 64.
    • The other 7 are the build and publish tooling tests (build-console-objectstack-root, build-schemas-check-mode, dist-freshness*, publish-smoke-*, published-projection-choke-point). They outlast the foreground cap on this shared box, so they are NOT MEASURED locally and left to CI's Test Core.
  • @objectstack/spec typecheck passes, on the merged head too.
  • @objectstack/lint: 148 files, 6935 passed, on the merged head.
  • @objectstack/platform-objects: 79 files, 1195 passed, on the merged head; its typecheck passes.
  • @objectstack/metadata-protocol: 249 files (3 skipped), 20771 passed.
  • @objectstack/objectql local: 448 files, 8447 passed.
  • @objectstack/rest local: 300 files, 6095 passed (326 skipped).
  • @objectstack/runtime local: 425 files in three batches, 6671 passed (19 skipped).
  • @objectstack/cli unit: 314 files in four batches, 4641 passed. The integration layer is declared to CI.
  • These five were run at c0c1a8ad33, before the merge. The merge brought no new filterableFields mention: git grep -c per file is identical at base and at origin/main.
  • Every example app runs os validate and os build (app-crm, app-todo, app-showcase, app-multi-package), and all pass. embed-objectql has no validate script.

Acceptance notes

  • Out-of-lane readers left in place, for the seat's follow-up. Each reads the key off raw input and none delivers anything once the parse refuses it. This matches the reasoning of the tabs retirement's stage 2: the lint suite judges the parsed stack.

    • packages/lint/src/validate-list-view-field-refs.ts (role map and severity map) and its test fixtures;
    • packages/lint/src/validate-react-page-props.ts (REACT_FIELD_SPECS.ListView) and its "covers the legacy filterableFields shorthand" test;
    • packages/lint/src/validate-field-consumers.ts:307;
    • packages/lint/src/rule-explanations.ts:3114 and :3149, which still name the key as a checked position in os explain text;
    • packages/lint/src/reference-integrity-suite.ts, in comments;
    • packages/metadata-protocol/src/metadata-diagnostics.ts:237 and the packages/objectql/src/metadata-diagnostics.test.ts fixtures;
    • packages/metadata-core/src/object-schema-fls-references.ts (LIST_VIEW_NAME_LISTS.filterableFields).

    The tree-scoped pin's RESIDUE set holds the four that spell it as an object key, and goes red when the follow-up deletes them.

  • Boundary: a react page. A react ListView element's filterableFields prop lives in a react page's opaque source string, and no door parses that string against ListViewSchema. The lint still treats it as a field-bearing prop ("honored by the renderer"). Once objectui removes its reader, the prop becomes a silent no-op on react pages unless the react-props lint learns to refuse it. The boundary is stated in the schema comment and the ledger note.

  • objectui follow-up (the seat files it). Re-read at the moved pin 12dc7b47:

    • app-shell/src/views/ObjectView.tsx:3448 forwards the key;
    • plugin-list/src/ListView.tsx:4656 is the filterFields whitelist;
    • plugin-view/src/ObjectView.tsx:3124 forwards it, :1030 keeps a key list, and plugin-view/src/index.tsx:90 registers an input;
    • types/src/objectql.ts:2625 and :2966 declare it locally;
    • types/src/zod/objectql.zod.ts:1300 reads .shape.filterableFields by reference.

    No export is removed and that member still exists as the tombstone, so the pinned objectui build compiles unchanged. The refusal flows into objectui's zod twin on the next pin bump.

  • ADR-0047 text is now stale. Decision 1 says "filterableFields stays as the legacy shorthand; loaders auto-upgrade it to userFilters.fields entries". That auto-upgrade was never built, and the ruling now forbids it. Its own section 4 calls the alias "one-release courtesies, not commitments". An amendment line is a docs/adr/** (Tier H) edit outside this PR's surface. It is raised in the report as an open question for the seat.

  • Out-of-repo consumers: NOT MEASURED. @objectstack/spec is published.

Gates

The derivation re-ran on the final head bb71914fc9 with no paths: dispatch-gates --commands --repo objectstack-ai/objectstack, 24 paths against merge base 1c1144fcd, giving 115 commands. All 115 were run on bb71914fc9, each exit code captured before any pipe, and --ran reconciles them as "115 derived famil(ies) accounted for — 115 run, 0 NOT-MEASURED (a DERIVED zero)".

  • One gate needed its prerequisite first. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: 7 unrelated packages had no dist/). It passed after building them.
  • Key verdict lines:
    • check-adr-0087-registration: "registered view-list-filterable-fields-removed, list-view-filterable-fields-retired (new here: …)";
    • check:api-surface: "public API surface + factory signatures unchanged" (key-level narrowing, no export moved);
    • check:docs: "227 generated files in sync";
    • check:spec-changes and check:upgrade-guide generate in memory;
    • check:doc-authoring: clean;
    • check:cross-package-test-inputs: OK;
    • check:i18n: OK;
    • check:nul-bytes: OK;
    • check-empty-changeset and check-changeset-no-major: pass.
  • Also run locally: the local-only check:dev-prereqs passes. check:skill-examples, check:liveness and check:generated are in the 115.
  • CI's own runs (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core) are read from this PR's check-runs. They are not re-run locally.

Generated by Claude Code

…ter-panel whitelist replaces it

The key was declared as the userFilters.fields shorthand while its one
renderer read it as the Filter panel's field whitelist. Ruled C: the key
retires with a retiredKey() tombstone on the list-view shape, the Studio
form input goes, and a D2 conversion DROPS it from stored and authored
view sources (never a rewrite to userFilters.fields), with its D3 entry,
RETIRED_KEYS_BY_MAJOR rows, step-18 rationale and liveness row.

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…ss counts for the filterableFields retirement

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…ithout the retired filterableFields input

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…673 with the retired filterableFields input

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/spec, touching 10 documentable anchor(s). ⚠️ 7 changed file(s) yielded no anchor (packages/spec/authorable-surface/ui.json, packages/spec/liveness/state-counts/view.md, packages/spec/liveness/view.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

⛔ 1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via retiredAfter (symbol, a field of const object viewListFilterableFieldsRemoved), retiredFromLoadPath (symbol, a field of const object viewListFilterableFieldsRemoved))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 7 changed file(s) yielded no anchor (packages/spec/authorable-surface/ui.json, packages/spec/liveness/state-counts/view.md, packages/spec/liveness/view.json, …) — pages documenting those are invisible to this run
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 54159fd742e5a79fc10ac41854dba3395e1a1ffe → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 770c2759b7f3cff12e3718703e084d83527d3eff — the merge of head bb71914fc92a679303b28361f1cd9384bb9baa30 into base 54159fd742e5a79fc10ac41854dba3395e1a1ffe, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 770c2759b7f3cff12e3718703e084d83527d3eff && git checkout 770c2759b7f3cff12e3718703e084d83527d3eff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 54159fd742e5a79fc10ac41854dba3395e1a1ffe bb71914fc92a679303b28361f1cd9384bb9baa30 && git checkout -B drift-repro 54159fd742e5a79fc10ac41854dba3395e1a1ffe && git merge --no-ff bb71914fc92a679303b28361f1cd9384bb9baa30

node scripts/docs-audit/affected-docs.mjs --json 54159fd742e5a79fc10ac41854dba3395e1a1ffe

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 54159fd742e5a79fc10ac41854dba3395e1a1ffe → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bb71914fc92a679303b28361f1cd9384bb9baa30
Local-runs: none
Scope: packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/retired-keys/18.ui__ListView__filterableFields.ts, packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectListView__filterableFields.ts, packages/spec/src/migrations/entries/semantic/18.list-view-filterable-fields-retired.ts, packages/spec/src/migrations/registry.ts.template, packages/spec/src/ui/view.form.ts, packages/spec/src/ui/view.zod.ts, packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts, packages/spec/src/ui/view.test.ts, packages/spec/src/shared/retired-key-removal-version.pin.test.ts, packages/spec/vitest.repo-tests.json, packages/spec/authorable-surface/ui.json, packages/spec/liveness/view.json, packages/spec/liveness/state-counts/view.md, .changeset/24640-list-view-filterable-fields-retired.md, content/docs/concepts/architecture.mdx, content/docs/references/api/protocol.mdx, content/docs/references/data/object.mdx, content/docs/references/ui/view.mdx, packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts, packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts, packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts, packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts, packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts + packages/spec/src/shared/retired-key.ts, packages/spec/src/conversions/types.ts, packages/spec/src/conversions/walk.ts (mapViewPayloads), packages/spec/src/conversions/stored.ts (applyConversionsToStoredItem), packages/spec/src/conversions/retired-after.census.json, packages/spec/src/conversions/retired-after.census.test.ts, packages/spec/src/migrations/entries/README.md, packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__bordered.ts, packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__bordered.ts, packages/spec/src/ui/component.zod.ts (ListViewPageProps), packages/spec/src/ui/view-list-tabs-retirement.test.ts, packages/spec/scripts/build-schemas.ts, packages/spec/authorable-surface.base.json, scripts/check-adr-0087-registration.mjs, packages/cli/src/commands/lint.ts, packages/cli/src/commands/validate.ts, packages/lint/src/validate-list-view-field-refs.ts, packages/lint/src/validate-react-page-props.ts, packages/lint/src/validate-field-consumers.ts, packages/lint/src/rule-explanations.ts, packages/metadata-protocol/src/metadata-diagnostics.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-core/src/object-schema-fls-references.ts, docs/adr/0047-object-ui-run-modes.md, .changeset/pre.json, packages/spec/package.json; objectui at origin/main df943a2a1: packages/app-shell/src/views/ObjectView.tsx, packages/plugin-list/src/ListView.tsx, packages/plugin-list/src/index.tsx, packages/plugin-view/src/ObjectView.tsx, packages/plugin-view/src/index.tsx, packages/types/src/objectql.ts, packages/types/src/zod/objectql.zod.ts

Read against the merge base 1c1144fcd80505f3575d85f597b33bc5791ca86c (24 files, 944 added, 37 deleted; the file list matches the PR's). Inputs were the card's body and all eight comments, the PR body and diff, the cited code at base and head, objectui at origin/main, and the head's check-runs. The dispatch's conclusions were not weighed.

① Derived judgments

D1 — ruling C and no wider: RIGHT. The diff removes exactly one authorable key, filterableFields, from one shape, ListViewShapeSchema (view.zod.ts), by retiredKey(LIST_VIEW_FILTERABLE_FIELDS_RETIRED). Every list-view door derives from that shape by reference, read at head: ListViewSchema; ObjectListViewSchema is ListViewShapeSchema.omit({ userFilters }).extend(...); the runtime write door's list overlay arm (ListViewOverlayWireSchema) is ListViewShapeSchema.extend(...); a page list-view node's ListViewPageProps spreads ListViewSchema.shape. So the tombstone is refused at every door the pin names, eight in all, and getMetadataTypeSchema('view') is pinned to ViewMetadataSchema. No replacement key is added anywhere in the diff. searchableFields and userFilters are not touched in view.zod.ts; the diff's only other view.zod.ts change is the prescription constant and the docblock. The Studio form row leaves (view.form.ts) and userFilters stays under End-user controls (view.form.ts:170-175).

D2 — the prescription is true: RIGHT. It names userFilters with the dropdown element and a fields array of { field } entries, which is the UserFiltersSchema shape, and says the Filter panel offers every field the user may read, field read security deciding, a field the object marks hidden: true not offered. Checked against objectui origin/main packages/plugin-list/src/ListView.tsx:4654-4680, the filterFields memo: with no whitelist the candidate list is candidateFields filtered by canReadField(perms, objectName, field) and then by !isHidden(field) || held.has(field). That is every readable field minus hidden ones, with one edge the prescription does not mention and does not need to: a hidden field already held by a saved condition stays offered. The closing os migrate meta --from 17 sentence is the standardized one (retired-key.ts). No tracker number in the prescription, pinned.

D3 — a DROP, never a rewrite: RIGHT. view-list-filterable-fields-removed applies mapViewPayloads over the stack and, for a payload whose kind is list, returns stripKeys(payload, ['filterableFields'], emit, path), every other payload untouched: byte-for-byte the shape of the 17.x view-list-passthrough-keys-removed and the 18.x view-list-tabs-removed. stripKeys emits to: '(removed)' and deletes; nothing writes userFilters. Pinned: notices read (removed), no userFilters is minted where none was written, an authored one rides through unchanged, and a second replay converts nothing.

D4 — the conversion metadata is right by the registry's own rules: RIGHT. toMajor: 18 matches PROTOCOL_VERSION major 18 (Check Changeset's lockstep line). retiredFromLoadPath: true is the pair of the tombstone, as RetiredConversionState in types.ts describes: the authoring funnel no longer replays it, so a live author meets the tombstone, while applyConversionsToStoredItem pins includeRetired: true and strips a stored row. retiredAfter: '17.7.0' follows the rule on that field: under a prerelease label (@objectstack/spec is 18.0.0-next.5 at head) it is the newest published stable, never the label. The census's last release is 17.4.0, so retired-after.census.test.ts admits the half-open range from 17.4.0 up to but excluding 18.0.0; 17.7.0 is inside it; the Check Changeset job's own fetch lists @objectstack/spec@17.7.0 as the newest stable spec tag before the 18.0.0-next series; and 17 other step-18 entries at head carry the same stamp. The entry is inserted in MAJOR_18_CONVERSIONS in id order with order: 71, unique in that table; the rationale fragment's order: 105 is unique in STEP18_RATIONALE.

D5 — the D3 entry and the retired-keys entries are true: RIGHT. 18.list-view-filterable-fields-retired.ts names the surface, says nothing replaces the whitelist, names userFilters as the curated bar, states the stored-whitelist consequence honestly (the panel widens; a hidden field the whitelist named is no longer offered) and names the object-listViews boundary. The two retired-keys files follow entries/README.md exactly: filename derived from the id, the // run above export const entry, one per walked def, as the 17.ui__ListView__bordered / 17.ui__ObjectListView__bordered pair does. check-adr-0087-registration requires registered ids to resolve at head and be new in this diff; both are.

D6 — the coverage boundary is honest and acceptable, with one stated cost: RIGHT. mapViewPayloads (walk.ts) walks stack.views[] in its three spellings and nothing else. A grep of the head registry finds no conversion that reaches objects[].listViews.*; applyConversionsToStoredItem('object', row) therefore returns the row with the key still on it and ObjectSchema refuses it, which the pin's BOUNDARY test asserts. That does leave a stored object row carrying the key un-resavable without a hand edit. It is stated in the three places an operator reads: the changeset's stored-row paragraph (ships as CHANGELOG.md), the D3 entry's reason (the upgrade-guide projection) and the conversion docblock. The same boundary is carried by view-list-tabs-removed and the other list-view conversions, the object Studio form offers no listViews input at all (object.form.ts), and the in-tree population is zero. Accepted on that precedent; not a rider this PR could close without a new object-collection walker, which is its own card.

D7 — generated artefacts are regenerated and consistent: RIGHT. authorable-surface/ui.json: the two rows become [RETIRED], the bordered precedent's spelling. authorable-surface.base.json is untouched, which is correct: build-schemas.ts says the anchor is rewritten only by --update-base, by hand, and the deletion gate reconciles a [RETIRED] shard row against the anchor through RETIRED_KEYS_BY_MAJOR; Lint & Repo Gates is green. liveness/view.json: the row flips live to dead with verifiedAt and evidenceScope: cross-repo, and its note names the reader truthfully against objectui origin/main (the filterFields memo, fed by app-shell ObjectView.tsx and plugin-view ObjectView.tsx, withheld on a page node by plugin-list/src/index.tsx). Counting statuses in view.json at base and head gives live 72 to 71, dead 11 to 12; state-counts/view.md reads 79 to 78 and 11 to 12 because it sums the type's shards, so the deltas agree. The three reference pages carry ten rows that are each the prescription string verbatim, the generator's output. The four metadata-forms bundles each lose the same four-line filterableFields block and nothing else, which is what check-i18n-bundles.mjs --write produces when a source input leaves.

D8 — the pins hold the change: RIGHT. Per door: the refusal with issue code invalid_type, the path naming the key, the prescription, and the key-first convention; a control without the key; a boundary with userFilters and searchableFields. Plus the empty-array refusal, the registry binding, the prescription's content and its tracker-number absence, the tsc channel (@ts-expect-error under the test-layer typecheck, with the parse throwing the same prescription), the form walk with anti-vacuity neighbours, the stored-seam replay with notice ids and paths, the never-a-rewrite pin, the three-spelling reach plus idempotence, the retired-from-load-path pin, the object-listViews boundary, both RETIRED_KEYS_BY_MAJOR[18] ids, the conversion id in the step-18 chain, the D3 entry's presence and content, a tree-scoped absence walk with a declared radius and a matcher battery, and the JOIN row for both defs in retired-key-removal-version.pin.test.ts. The dev's ablation count is consistent with that control flow: restoring the plain declaration fails the 8 door refusals, the empty-array refusal, the prescription-content pin, the tsc-channel parse, the object-listViews boundary (the object now parses) and the JOIN row, 13 in all, while the controls, boundaries, form walk, D2 pins (independent of the shape), registration pins and the tree walk hold.

D9 — the RESIDUE assertion is right, with its cost named. The assertion is the last loop of the test no list-view payload carrying filterableFields survives inside the declared radius: expect(residueHits.get(rel) ?? 0, rel + ' no longer spells the key — delete its RESIDUE entry').toBeGreaterThan(0), over the four RESIDUE paths. It ratchets the right way. A RESIDUE entry skips the offender check for that file; without the greater-than-zero guard an entry would outlive its residue and silently exempt a future re-authoring there, which is exactly the allowlist the pin's own comment refuses. The red it produces on cleanup is the instruction to delete the entry in the same PR, and the message says so. It does not block the cleanup; it couples it: a packages/lint, metadata-core or objectql cleanup must also edit packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts, one extra file, which check:cross-package-test-inputs already declares for this test. This is the view-list-tabs-retirement.test.ts shape verbatim (its RESIDUE set and the same assertion at its line 560). One difference worth knowing: the tabs pin's matcher requires a list-view sibling key beside tabs, while this pin counts any object literal whose own keys include filterableFields, which is why a lint severity map and a scrub vocabulary map count as offenders here. Looser, and loud rather than silent; acceptable.

D10 — changeset body: RIGHT. BREAKING banner, the FROM to TO table with both meanings the key carried, the one-line fix, a before/after block, the stored-row paragraph including the object-row sentence, the untouched-keys sentence, the not-measured out-of-repo note, Clause-②: yes (narrowing), and the ADR-0087 marker registered naming both new ids.

D11 — out-of-lane paths: RIGHT and minimal. packages/platform-objects/**: four generator outputs, and object-lifecycle-panel-echo-decisions.test.ts moves its positive-control count 674 to 673 with a provenance comment in the style of the prior entries; the removed input's label was authored in all three locales, so the catalog loses exactly one, which the four bundle diffs show. content/docs/**: three generated pages, and architecture.mdx replaces the retired key in a defineView list example with userFilters: { element: 'dropdown', fields: [{ field: 'industry' }] } and one comment line. That is a legal ListViewSchema.userFilters value, the example remains a list view, and the dropped annual_revenue entry was a number field that a dropdown quick filter would not suit. True against the spec.

② Semver level

@objectstack/spec minor with BREAKING is what this diff publishes under the repository's rule: Clause-②: yes (narrowing) is BREAKING and takes at least minor, and the 18 prerelease train admits no major bump (Check Changeset: "This diff introduces no major bump"; "LEVEL AXIS: this PR declares clause-② yes (narrowing), and no package whose published source it moves is graded patch"). check:api-surface reads unchanged because no export moves; the narrowing is at the key level and is carried by the tombstone's never input type, the parse refusal and the [RETIRED] rows. The Clause-②: yes (narrowing) line is on both carriers, the PR body and the changeset. The .changeset/pre.json reading is literal: the file is { "mode": "exit", "tag": "next" } and nothing else; the spec label is 18.0.0-next.5; a minor increment of that prerelease under exit mode is 18.0.0, so the sentence "the next version pass publishes this in 18.0.0" is true. The ADR-0087 registration is registered view-list-filterable-fields-removed, list-view-filterable-fields-retired, both ids new in this diff and resolving at head; check-adr-0087-registration is green inside Check Changeset.

③ Boundary flags

F1 — the dev's deviations (a) to (f): accepted. (a) The JOIN row, the rationale fragment, the vitest.repo-tests.json line and the two extra reference pages are each demanded by a gate or a generator on this surface. (b) The platform-objects count edit is declared above as D11 and accepted at this tier; it is the one out-of-lane hand edit. (c) and (d) are run-shape notes; the merge brought no new mention of the key, confirmed by the head-wide census (31 files mention it, every one accounted for by the diff, the residue, release-owned CHANGELOGs or the tabs pin's vocabulary list). (e) Seven build and publish tooling tests are not measured locally and belong to Test Core, which is in progress at this read. (f) Reported, not a deviation.

F2 — ADR-0047 Decision 1 is falsified by this PR; a governed follow-up, not a blocker. Confirmed at docs/adr/0047-object-ui-run-modes.md:154-155: "filterableFields stays as the legacy shorthand; loaders auto-upgrade it to userFilters.fields entries." After this PR the key does not stay and the ruling forbids the auto-upgrade, which was never built. The ADR's own section 4 Non-goals (:253-254) already calls that alias a "one-release courtesy, not a commitment", and the maintainer's verbatim 「C」 (record 6278975049) is the superseding word. Prime Directive 13 wants the record amended, and docs/adr/** is Tier H, so the amendment is a docs-only PR of its own, the dev's option A; folding it here would make this PR Tier H for a one-line status note. The seat files it when this lands. ADR-0047 is untouched by the diff.

F3 — finding (1), the in-repo readers: reachable on one path, not dead; no duplicate beside the tombstone; follow-up. os validate exits 1 at the ObjectStackDefinitionSchema parse before any rule runs, so the tombstone is the only finding there. os lint never Zod-parses (lint.ts: a schema error is os validate's verdict) and runs the reference-integrity suite over normalizeStackInput output, which replays only live conversions, so a raw filterableFields survives to validate-list-view-field-refs.ts (:392 severity error, :534 role filter) and validate-field-consumers.ts:307. On that path a valid name produces no finding, so a retired key passes os lint silently, and a stale name produces a field-reference error that names the field rather than the retirement: a misleading finding only in that case, and a class that predates this PR for every tombstoned key, since os lint raises no tombstone at all. metadata-diagnostics.ts:237 reads the key off a served view, but the served row has been through applyConversionsToStoredItem (protocol.ts:6783), which strips it, so that read is dead for stack.views[] spellings; the tabs retirement removed its own walk there and left a comment at :231-235, and this PR leaves the sibling walk in place. rule-explanations.ts:3114 and :3149 still name the key as a checked position in os explain text. object-schema-fls-references.ts:719 keeps it in the ADR-0106 scrub vocabulary. None of these is an author, none is reached by a parsed stack, and the pin's RESIDUE set tracks the four that spell it as a key. Follow-up: delete the spellings, the metadata-diagnostics walk and the react-props mention together, and let the RESIDUE entries leave.

F4 — finding (2), the react ListView element's filterableFields prop: confirmed; follow-up with objectui's reader removal. REACT_FIELD_SPECS.ListView.fields (validate-react-page-props.ts:773) lists the prop as field-bearing and its test (validate-react-page-props.test.ts:692-694) authors it inside a JSX source string. A react page's source is parsed against no list-view schema, so the tombstone cannot reach it; objectui's reader at origin/main still honours it, so the prop works today and becomes a silent no-op once objectui's follow-up lands. The react-props lint should then refuse it by name rather than check its field references. Not this PR's surface; it belongs with the objectui follow-up.

F5 — finding (3), the objectui follow-up sites: confirmed at origin/main df943a2a1. packages/app-shell/src/views/ObjectView.tsx:3464 forwards viewDef.filterableFields ?? listSchema.filterableFields (the PR's :3448 has moved); packages/plugin-list/src/ListView.tsx:4654-4680 is the filterFields whitelist memo; packages/plugin-view/src/ObjectView.tsx:3124 forwards it and :1030 keeps it in a key list; packages/plugin-view/src/index.tsx:90 registers the input; packages/types/src/objectql.ts:2625 and :2966 declare it locally; packages/types/src/zod/objectql.zod.ts:1300 reads .shape.filterableFields by reference, so the tombstone flows into the twin on the next pin bump while the member still exists and the pinned build keeps compiling; packages/plugin-list/src/index.tsx:123 withholds it on a page list-view node. The seat files the objectui card when this lands, as the dispatch says.

F6 — the check-runs on this head, read at 2026-10-20T02:20Z. Completed success: Lint & Repo Gates (with its seven lanes), Build Core, Build Docs, Governed Surface Queue Guard, Check Changeset, Spec property liveness, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Dogfood Regression Gate, Auto Label, the three claim checks and the Part-of check. In progress: Test Core (1/6) through (6/6) and Type Check · workspace; the TypeScript Type Check aggregate had not posted. Skipped, full battery only: Console Pin Gate, Temporal Conformance, the dogfood shards, Dogfood Verify CLI, Packed-tarball smoke. The required set is not yet whole; this record's verdict is on the diff and the cited code, and the queue waits for those conclusions as it always does.

F7 — governed surfaces: none touched. No path under docs/adr/**, .claude/**, skills/**, .github/workflows/**, AGENTS.md or CLAUDE.md; the queue guard is green; the PR is 981 changed lines.

Implemented-by: claude/issue-24640-retire-filterable-fields
Reviewed-by: session_019oFY6Hi35J7atvgVFnmoaK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review adopted · domain:spec seat 2 · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-20T02:39Z


Generated by Claude Code

os-justin pushed a commit that referenced this pull request Oct 20, 2026
ADR-0047's Decision 1 (section 3.3 item 1) still said `filterableFields`
"stays as the legacy shorthand; loaders auto-upgrade it to
`userFilters.fields` entries". The maintainer's ruling C on #24640 retired
the key in 18.0 with no replacement key, and PR #24721 (4bc3bec) executed
it: a retiredKey() tombstone refuses it at parse, and the conversion
`view-list-filterable-fields-removed` drops it rather than rewriting it to
`userFilters.fields`. The auto-upgrade loader the sentence promised was
never built.

Per Prime Directive 13 the record is amended in place: a dated
Amendment (2026-10-20) blockquote joins the status block's existing
Revision and Amendment chain, and Decision 1's sentence is struck with
an annotation pointing at it, not deleted. No other ADR text moves.

Claude-Session: https://claude.ai/code/session_01HEdP5tPhqQfzwRmKDxwn85
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

2 participants