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
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Aug 14, 2026
  2. added theissue type on Aug 14, 2026
  3. 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

  4. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 15, 2026
  5. 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

  6. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 16, 2026
  7. 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

  8. 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

  9. 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

Labels

domain:specDomain lane — seat card indexed by label:pm:seatenhancementNew feature or requestpm:dispatchedDispatched to a dev agent by /pm-dispatch

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions