Skip to content

spec(data): a record-scoped filter token {record_id} — resolved from the mounted record context, refused by name wherever there is none (spec half of objectui#7297) #20003

Description

@objectstack-fleet

What is missing

On a type: 'record' page, the only component that can scope itself to the record in view is record:related_list (via relationshipField / relationshipValueField). Every other data-bearing component, including element:number and any component-level dataSource, takes a FilterCondition. The only dynamic values a filter can hold are the CONTEXT_TOKENS:

// packages/spec/src/data/context-tokens.zod.ts (main bfa23a8f49)
export const CONTEXT_TOKENS = [
  'current_user_id',
  'current_org_id',
] as const;

Both tokens name the signed-in viewer. None names the record the page is bound to.

The result is a silent wrong answer. An author puts "open tasks" at the top of a person's record page and writes the obvious filter. The page shows a count over the whole organisation, under that person's name, and it reads as their number.

The same component can already bind the record in its visibleWhen predicate ("Page predicates bind the live page surface: record + current_user …"). So authors reasonably expect the filter to do the same.

This has already cost a delivery. objectstack-ai/duly#13 shipped three record:related_list tables where three numbers were wanted. The full measurement is on objectui#7297.

Maintainer ruling

The triage seat put this to the maintainer on 2026-09-24 as decision ② of six, recommending: do {record_id}; with no record context it must fail with a clear error, never quietly become an empty value.

The maintainer's answer, verbatim: 「需要我决定的 6 件事: 6947 7300 不做;其他同意」. #7297 is among "其他", so the recommendation stands as ruled.

What the spec has to say

This card owns the vocabulary and its contract. The objectui resolver is objectui#7297's own work, and it stays blocked on this card.

1. The token is recognised by name

classifyFilterToken('{record_id}') must stop returning kind: 'unknown'.

You decide whether it joins CONTEXT_TOKENS or sits in a sibling list. The case for a sibling list: the module header defines CONTEXT_TOKENS as "resolve against the caller's session", and {record_id} does not; it resolves against the surface. If it joins CONTEXT_TOKENS, the header and the "Deliberately tiny … three surfaces and one lint pass" note must be rewritten to say so.

2. The server refuses it, by name

The server resolver cannot know which record a page is showing. It is resolveFilterTokens() in packages/core/src/utils/filter-tokens.ts:376, which runs on the ObjectQL read and write paths and in the analytics dataset executor.

So a filter that reaches the server with {record_id} still in it must throw a named error. It must not resolve to null, undefined or the literal string. This matches the rule the header already sets for a request with no userId or tenantId: "resolving to null degrades to IS NULL on most drivers and would hand back the rows the filter was written to exclude."

It also closes the reverse trap. Today the number is "about everybody"; the fix must not turn it into a number "about nobody".

3. The lint pass allows it only where a record context exists

packages/lint/src/validate-filter-tokens.ts must allow {record_id} in filters on components of a type: 'record' page. It must refuse it by name on:

  • list views;
  • dashboard widgets;
  • reports;
  • non-record pages.

Each refusal must say why ("no record in context on this surface"), not the generic unknown-token message.

The pass can already do this: walkAuthoredFilters (lint/src/filter-walk.ts) reports each filter's FilterSurface, and the rule declares its own seven surfaces.

4. The documentation says it is presentation scope

Like {current_user_id}, {record_id} scopes what a surface shows. It is not an access boundary.

The "Where the tokens are honoured" section must list which surfaces honour it. It must also keep it apart from two look-alikes:

  • the existing {recordId} URL / flow-template placeholders (action.zod.ts newTabUrl, lint/src/flow-template-grammar.ts), which are a different mechanism;
  • the page-variable type 'record_id' (page.zod.ts:454).

5. A changeset

The change adds a token, so it is additive. Per AGENTS.md changeset rules, the changeset must say which surfaces now accept the token and which refuse it.

Acceptance

  • classifyFilterToken recognises {record_id}. Tests pin a lit control ({current_user_id} still resolves as before) and a near-miss ({recordid} or {record-id} is still refused with a suggestion).
  • resolveFilterTokens throws a named error on {record_id}, on the read path, the write path and the dataset executor, each with a test.
  • The lint pass accepts it on a record-page component and refuses it on a list view, a dashboard widget and a non-record page, each with a test and the "no record in context" wording.
  • The module header and the docs table match the new contract.
  • A changeset is included.

How this unblocks objectui#7297

objectui#7297 is unblocked when an objectui checkout can install a published @objectstack/spec that carries the token. Merging here is not enough: @objectstack/spec latest is 17.4.0 today, and that is what objectui resolves.

objectui#7297 carries the executable probe for that condition.

Out of scope

  • RLS current_user.* expressions.
  • Navigation AppContextSelector ids.
  • titleFormat field interpolation.
  • Any token beyond {record_id}: related-record tokens, parent-of-record tokens and the like each need their own card and their own ruling.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions