Repository navigation
feat(spec): no declarative filter for auto-derived related lists — add field-level relatedListFilter so soft-deleted child rows can be excluded without abandoning the auto record page #8704
Description
Activity
Triage: lands in
packages/spec(field-level related-list declaration) with a consuming half in objectui'sRecordDetailViewderivation. Routeddomain:spec, typeFeature,needs-user-decision.Why the decision box and not the queue: adding an authorable key widens the accept surface of the spec, which is the maintainer's floor (feature/contract-shape class) — not a triage-adjudicable card, however well-evidenced. Contract-first also means this cannot be one dispatch: the spec key lands here first, and the objectui consumption half follows as a
Blocked-by:-linked card once the shape is ruled.One thing the ruling should weigh, surfaced by the routing read. The motivating scenario is a child object that implements soft deletion in userland (
status = 'deleted'). The platform declares soft deletion and does not implement it: #3146 recordsenable.trash/softDeleteas spec-only with zero runtime readers (status:parked). So the measured pull here may be evidence for two different features, and they are not equivalent:- if the platform shipped soft delete, an auto-derived related list would exclude trashed rows by construction, with no new authorable key and no per-field filter to get wrong;
relatedListFilteris the more general capability and serves cases soft delete never will — but it is also a general filter surface on a declaration, which is a permanent obligation with its own semantics to pin (AND-composition, badge-count parity, and what happens when the filter names a field the child object later drops).
Neither displaces the other automatically; the point is that ruling this card without looking at #3146 risks paying for a general filter mechanism to work around a specific capability the platform already claims to have.
Four-facet card face
- Platform long-term coherence — mildly widening. It adds a fourth key to the field-level related-list family (
relatedList/relatedListTitle/relatedListColumnsatspec/src/data/field.zod.ts:788-792), which is a coherent place for it, and it closes a real asymmetry: therecord:related_listcomponent acceptsfilter(objectui#3981, from record:related_list.filter 声明了却无人读:RelatedList 的列表查询只按父关系过滤,作者写的 filter(以及 dataSource 合成出的 filter)整条丢弃 #7118) while the declaration that auto-derives that component cannot express the same thing. Against: a filter on a declaration needs its composition semantics pinned, or the two paths drift into a second dialect. - Measured business pull — present and named, which is rare for this class: a downstream re-test batch in Titanwind EHR (gap ③), against
@objectstack/*17.0.0, with the workaround's cost stated concretely (abandon the auto-derived record page — and with it every future platform improvement to that page — to express one filter condition). This is pull for the capability; it is not by itself pull for this particular spelling. - AI-agent error-resistance — favours declaring it. The current escape hatch is "hand-write the entire page", which is precisely the shape that goes wrong at scale when an AI author takes it: a page frozen at today's platform behaviour, maintained by nobody. A closed, declared filter keeps the author on the derived path. The failure mode to design against is the badge count: if the count and the rows can disagree, the feature ships a silent lie, so count-honours-filter belongs in the contract rather than in the consumer.
- Startup scope discipline — every declared key is a permanent obligation, and this one must be honoured by the auto-derivation and the badge count and whatever renders related lists next. The discipline-respecting version of "yes" is the narrowest spelling that serves the measured case, with the enforcement written before the key ships (declared-and-unenforced on a filter surface is the expensive failure this backlog is already full of).
PM recommendation (offered, not decided): rule the #3146 question first, since it may dissolve the motivating case at lower total cost; if
relatedListFilteris then still wanted, admit it in its narrowest form with badge-count parity written into the contract as a pin, not left to the consumer. Naming and placement are the platform's call, as the reporter notes.Not a claim. Dispatch tier when it is ruled and queued:
claude-fable-5(mandatory — this changes the contract's accept surface).
Generated by Claude Code
Maintainer ruling (2026-08-15, verbatim: 「接受全部建议。」, item 3 of the five-item list posted in the live PM session) — the capability is accepted, under four conditions recorded as binding:
- Reuse the existing declarative filter schema — no new filter dialect. The declared filter flows through the same validation doors as every other filter, which means the FILTER-axis refusals (formula fields etc., The FILTER axis has no unmaterializable verdict: a
whereon a virtual formula field returns 0 rows silently, while sort and search refuse the same field with a 400 #8296/The FILTER-axis formula refusal (#8296) shipped without the ADR-0087 semantic entry its SORT-axis twin (#7095) carries — the upgrade guide will not mention it #8370) apply to it automatically. - Semantics as this card states them: an authored constraint AND-composed with
{ [referenceField]: parentId }, never a user-editable suggestion; the tab badge count honors the same filter. - Contract-first split: this card carries the spec half — the
relatedListFilterkey joining the existing three-key family atpackages/spec/src/data/field.zod.ts:788(relatedList/relatedListTitle/relatedListColumns). The objectui consumption half (RecordDetailView's auto-derived descriptors + badge) is filed separately withBlocked-by:this card — companion card follows this comment. - 17.x minor (pure accept-set widening, Feature class; ordinary minor changeset, no BREAKING annotation — this expands, not narrows).
needs-user-decision→pm:queuein the same stroke. Queue position under SERIAL mode: third, after #8762 (Bug) and #8715 (retirement).
Generated by Claude Code
- Reuse the existing declarative filter schema — no new filter dialect. The declared filter flows through the same validation doors as every other filter, which means the FILTER-axis refusals (formula fields etc., The FILTER axis has no unmaterializable verdict: a
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsClaim: PM loop round 5 (spec seat; batch-3 posture)
Session:session_01Fgvh1iEJfxetei7aNVdtJt
Branch:claude/issue-8704-related-list-filter
Worktree:objectstack-issue-8704
Domain:domain:spec
File surface (region-level):packages/spec/src/data/field.zod.ts(the three-key related-list family at ~:788 gainsrelatedListFilter), its tests, regenerated reference docs + authorable-surface/api-surface shards, changeset (stop on breach; explain in the report). ⛔ The objectui consumption half (RecordDetailView derivation + badge) is a separateBlocked-by:card — NOT this dispatch.
Container & model: M,mode:subagent,model: claude-fable-5— mandatory tier: widens the contract accept surface
Serial constraints cleared: in-flight #8876 (data/driver/common.zod.ts) — disjoint source files; #8936 in merge queue (filter-dotted-head+ doors) — disjoint; shared generated surfaces under the four waiver disciplines (mergemainbefore PR, re-merge after any sibling lands, os-regen relay if registry is somehow touched — not expected for a pure widening); no #8641 trigger-file intersection.Ruling being implemented (maintainer 2026-08-15 「接受全部建议。」 item 3, comment
5299772379, four binding conditions): ① reuse the existing declarative filter schema — ⛔ no new filter dialect, the declared filter flows through the same validation doors (the FILTER-axis refusals #8296/#8370/#8935/#8936 apply automatically); ② semantics: authored constraint AND-composed with{ [referenceField]: parentId }, never user-editable; badge-count parity written into the contract as a pin, not left to the consumer; ③ spec half only (contract-first); ④ 17.x minor, ordinary changeset, no BREAKING annotation.
Generated by Claude Code
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actions{ "issue": 8704, "status": "done", "branch": "claude/issue-8704-related-list-filter", "pr": "https://github.com/objectstack-ai/objectstack/pull/8955", "premise_still_valid": true, "summary": "Spec half implemented per the four-condition ruling: `relatedListFilter: FilterConditionSchema.optional()` joins the three-key family at packages/spec/src/data/field.zod.ts, with the AND-composition (`{ [referenceField]: parentId }`), never-user-editable, and badge-count-parity clauses written as normative contract text and pinned. Dialect measured, not assumed: FilterConditionSchema chosen over the component's ViewFilterRuleSchema[] because the #8793/#8935 preset door lives in its superRefine (pinned at the new position with zero new wiring) and the in-file precedent summaryOperations.filter already carries identical AND-with-parent-FK semantics; #8296/#8371(#8936, landed and merged under this branch) are field-typed runtime doors the composed query reaches by construction — door placement pinned. The auto-derivation contract is measured wholly objectui-side (deriveRelatedLists/RecordDetailView), so badge parity lands as normative prose + contract-text pin here and behavioural enforcement in the companion card, said explicitly in the PR body. Lint: FILTER_KEYS gains relatedListFilter (the walk recognises filter positions by key and would miss the new one), extending tokens/empty-combinators/preset-comparands at once, with position pins. Liveness: `planned` + authorWarn (the #4848 shape — an author who sets it today IS misled), flip-to-live instruction cites objectui#4664. 17.x minor changeset (spec minor, lint minor), no BREAKING, no ADR-0087 entry (pure widening, registry untouched — verified absent from the diff). Companion objectui card FOUND: objectstack-ai/objectui#4664 (already filed, open) — PM can wire Blocked-by. Existing three keys measured NOT schema-gated to lookup/master_detail; fourth gated identically. skip-changeset label not applicable (real changesets present).", "tests": "Union at head 3bc19f0 (quoted from the run): spec 406 files / 10725 tests passed + typecheck green; lint 73 files / 2047 passed + typecheck green; check:generated all 13 artifact gates green (authorable-surface carries data/Field:relatedListFilter; docs regenerated). Consumer suites: metadata 603, metadata-protocol 1512, metadata-core 162, metadata-fs 51 — all passed; examples validate x3 exit 0. Gates re-derived via scripts/pm/dispatch-gates.mjs on the actual 9 changed paths: 15 path gates + convention gates (query-options-erasure, type-check-coverage, type-check-debt --re-measure on the built closure) + check:nul-bytes all PASS; derivation added only docs/changeset path gates beyond the prompt's named families, all green. No ablation in this card, so no dist-rebuild claim is applicable. CI at report time: in_progress (report-at-draft-time per contract).", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsACCEPT — PR #8955 (
Fixes #8704, spec half), reviewed by the spec seat (sessionsession_01Fgvh1iEJfxetei7aNVdtJt) against the diff and CI:- All four ruling conditions discharged, each by measurement: ①
FilterConditionSchemareused (decisive in-file precedent:summaryOperations.filteralready carries identical AND-with-parent-FK semantics; the Refuse the declared relative-date preset vocabulary as a bare temporal comparand at publish time — the ruled C half of #8690, carved out for the spec seat #8793/feat(spec,lint): refuse a bare date-range preset name in an ordering filter comparand at publish time (#8793) #8935 preset door comes free via itssuperRefineand is pinned AT the new position; the The FILTER axis has no unmaterializable verdict: awhereon a virtual formula field returns 0 rows silently, while sort and search refuse the same field with a 400 #8296/[finding] The FILTER axis has no DOTTED-path verdict —where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 field-typed doors are runtime doors the composed query reaches by construction — door placement pinned so a blind schema-side refusal can't creep in); ② AND-composition + never-user-editable + badge-count parity written as normative contract text with a contract-text pin — and the honest boundary stated: the auto-derivation contract is wholly objectui-side, so behavioural parity enforcement lands with the companion card's acceptance criteria; ③ objectui untouched, companion objectui#4664 confirmed filed; ④ 17.x minor, no BREAKING, registry verified absent from the diff. - Two quality touches worth noting: the liveness entry is
planned+authorWarn(the [决策] SDUI 深链「导航即运行动作」(?runAction=<actionName>)要不要升格为 spec 声明的正式契约 #4848 shape — an author who sets the key today IS misled until the consumer lands; flip-to-live instruction cites objectui#4664), and the lintFILTER_KEYSaddition closes the walk gap class-wide (the first family member not spelledfilterwould otherwise be invisible to all three walking rules) with position pins. - Consumer readings: spec 10725, lint 2047, metadata family 603+1512+162+51, examples ×3 — green. CI at head
3bc19f0: all 29 check-run jobs green.
Marking ready + auto-merge. Generated surfaces are disjoint from the in-queue #8956 (authorable-surface/field.mdx/liveness vs api-surface/export-origins) — no relay needed between them. On MERGE: unlock note to objectui#4664.
Generated by Claude Code
- All four ruling conditions discharged, each by measurement: ①
- added a commit that references this issue
on Aug 17, 2026 - added a commit that references this issue
on Aug 30, 2026 - added a commit that references this issue
on Sep 1, 2026
Reported from a downstream re-test batch of platform-capability gaps in Titanwind EHR (private repo: steedos-labs/os-project-titanwind-ehr#1071, gap ③). All facts below are from
@objectstack/*17.0.0 and are self-contained — the downstream links are for provenance only.Current state (17.0.0)
The field-level related-list surface in
@objectstack/specconsists of exactly three keys —relatedList,relatedListTitle,relatedListColumns(spec/src/data/field.zod.ts:788-792). There is no filter dimension at all.Consequently, the related-list descriptors that RecordDetailView auto-derives for a record page carry only
childObject/childLabel/referenceField/isOwned/isPrimary(plus the title/columns overrides above), and the list query is issued directly as{ [referenceField]: parentId }— every child row pointing at the parent is shown, unconditionally.Scenario that breaks
A child object implements soft deletion (e.g.
status = 'deleted'). After a user "deletes" a child record:Current workaround and its cost
The only way to filter a related list today is to replace the entire page with a hand-written page using the
record:related_listcomponent, which does accept afilterinput (wired up in objectstack-ai/objectui#3981, from #7118). That works, but the cost is giving up the auto-derived record page for that object — every other related list, layout, and future platform improvement on that page now has to be maintained by hand, to express one filter condition.Note #7118 is the component-level read point; this issue is the missing declaration-level counterpart, so the auto-derived page can express the same thing.
Request
Add a field-level
relatedListFilterkey (or an equivalent related-list default-filter declaration) to the spec, and have the auto-derived related lists consume it:{ [referenceField]: parentId }— an authored constraint, not a user-editable suggestion;Naming/placement is the platform's call — the capability gap is "a declared filter that auto-derived related lists respect".