Skip to content

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

@baozhoutao

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/spec consists 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:

  • the parent's record page still shows the deleted row in the related list, unchanged;
  • the related-list tab badge count does not decrease;
  • the child object's own list-view filters do not apply to related lists, so there is no declarative way to hide the row anywhere on the auto-derived record page.

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_list component, which does accept a filter input (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 relatedListFilter key (or an equivalent related-list default-filter declaration) to the spec, and have the auto-derived related lists consume it:

  • semantics: AND-composed with the parent-relationship condition { [referenceField]: parentId } — an authored constraint, not a user-editable suggestion;
  • the tab badge count should honor the same filter, so counts match the visible rows.

Naming/placement is the platform's call — the capability gap is "a declared filter that auto-derived related lists respect".

Activity

  1. added theissue type on Aug 14, 2026
  2. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Triage: lands in packages/spec (field-level related-list declaration) with a consuming half in objectui's RecordDetailView derivation. Routed domain:spec, type Feature, 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 records enable.trash / softDelete as 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;
    • relatedListFilter is 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

    1. Platform long-term coherence — mildly widening. It adds a fourth key to the field-level related-list family (relatedList / relatedListTitle / relatedListColumns at spec/src/data/field.zod.ts:788-792), which is a coherent place for it, and it closes a real asymmetry: the record:related_list component accepts filter (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.
    2. 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.
    3. 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.
    4. 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 relatedListFilter is 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

  3. os-zhuang commented on Aug 15, 2026

    @os-zhuang
    Contributor

    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:

    1. 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 where on 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.
    2. 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.
    3. Contract-first split: this card carries the spec half — the relatedListFilter key joining the existing three-key family at packages/spec/src/data/field.zod.ts:788 (relatedList/relatedListTitle/relatedListColumns). The objectui consumption half (RecordDetailView's auto-derived descriptors + badge) is filed separately with Blocked-by: this card — companion card follows this comment.
    4. 17.x minor (pure accept-set widening, Feature class; ordinary minor changeset, no BREAKING annotation — this expands, not narrows).

    needs-user-decision → pm:queue in the same stroke. Queue position under SERIAL mode: third, after #8762 (Bug) and #8715 (retirement).


    Generated by Claude Code

  4. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Claim: 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 gains relatedListFilter), 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 separate Blocked-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 (merge main before 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

  5. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator
    {
      "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

  6. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    ACCEPT — PR #8955 (Fixes #8704, spec half), reviewed by the spec seat (session session_01Fgvh1iEJfxetei7aNVdtJt) against the diff and CI:

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions