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
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.
What is missing
On a
type: 'record'page, the only component that can scope itself to the record in view isrecord:related_list(viarelationshipField/relationshipValueField). Every other data-bearing component, includingelement:numberand any component-leveldataSource, takes aFilterCondition. The only dynamic values a filter can hold are theCONTEXT_TOKENS: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
visibleWhenpredicate ("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#13shipped threerecord:related_listtables 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 returningkind: 'unknown'.You decide whether it joins
CONTEXT_TOKENSor sits in a sibling list. The case for a sibling list: the module header definesCONTEXT_TOKENSas "resolve against the caller's session", and{record_id}does not; it resolves against the surface. If it joinsCONTEXT_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()inpackages/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 tonull,undefinedor the literal string. This matches the rule the header already sets for a request with nouserIdortenantId: "resolving tonulldegrades toIS NULLon 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.tsmust allow{record_id}in filters on components of atype: 'record'page. It must refuse it by name on: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'sFilterSurface, 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:
{recordId}URL / flow-template placeholders (action.zod.tsnewTabUrl,lint/src/flow-template-grammar.ts), which are a different mechanism;'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
classifyFilterTokenrecognises{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).resolveFilterTokensthrows a named error on{record_id}, on the read path, the write path and the dataset executor, each with a test.How this unblocks objectui#7297
objectui#7297 is unblocked when an objectui checkout can install a published
@objectstack/specthat carries the token. Merging here is not enough:@objectstack/speclatest is 17.4.0 today, and that is what objectui resolves.objectui#7297 carries the executable probe for that condition.
Out of scope
current_user.*expressions.AppContextSelectorids.titleFormatfield interpolation.{record_id}: related-record tokens, parent-of-record tokens and the like each need their own card and their own ruling.