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

  1. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01CiCTczDo7tGhafXjf61dUJ
    Account: os-sales (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-20003-record-id-filter-token
    Worktree: objectstack-issue-20003
    Domain: domain:spec
    Seat: domain:spec#4 (seat post #18917)
    File surface: packages/spec/src/data/context-tokens.zod.ts (the token vocabulary, classifyFilterToken, the module header docblock that generates the reference page) and its tests; packages/core/src/utils/filter-tokens.ts (resolveFilterTokens: the named refusal) and its tests; packages/lint/src/validate-filter-tokens.ts (and packages/lint/src/filter-walk.ts only if a surface kind must be exposed) and their tests; refusal pins on the ObjectQL read and write paths and the analytics dataset executor (test files only under packages/objectql/ and packages/services/service-analytics/); the error-code ledger entry only if a new code is owed; the generated artefacts that follow (content/docs/references/data/context-tokens.mdx, JSON Schema, api-surface), regenerated and never hand-edited; .changeset/. ⛔ Not packages/objectql/src/engine.ts (the domain:engine claim on #20148 holds its aggregation filter loop). Cross-domain surfaces (packages/core, service-analytics tests) are declared here because the card itself names resolveFilterTokens as the server refusal (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default judgment tier (dispatch-gates.mjs --tier at 2bbebf5: 「no path-derived mandate … floor · default · ceiling」 plus a clause-② SUSPECT hint on context-tokens.zod.ts; the default slot taken, the refusal shape per surface carries judgment)
    Clause-②: yes
    Thread-read: none
    Serial constraints cleared: read at 2026-09-27T04:10Z. Open-PR census (7 open PRs besides the release PR, 77 file rows, lit control 30 packages/spec rows): none touches context-tokens.zod.ts, filter-tokens.ts, validate-filter-tokens.ts, filter-walk.ts or context-tokens.mdx. In-flight claims read from their Claim: comments (seat 1: #19977, #19867, #19856, #19543, #17321, #17158; seat 2: #19965, #19870, #20051, #20105, #20078; seat 5: #19938, #19886, #19731, #18459, #17707; other lanes: #20153, #20148, #20136, #20113, #20055, #19879) name none of these files. #20078 edits packages/lint/src/validate-expressions.ts, a different file. #20148 (domain:engine) calls resolveFilterTokens from the aggregation loop in engine.ts, which this card does not edit. The serial predecessor PR #19818 on filter-walk.ts landed as 66960564d9.

    Ruling carried: the maintainer's answer quoted in the card body, 「需要我决定的 6 件事: 6947 7300 不做;其他同意」 (objectui#7297 among 「其他」), recommendation as ruled: do {record_id}; with no record context it fails with a clear error, never an empty value.

  2. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20003,
    "status": "done",
    "branch": "claude/issue-20003-record-id-filter-token",
    "pr": "#20180",
    "session": "session_01CiCTczDo7tGhafXjf61dUJ — this run is a subagent under the PM session; the Claim: comment 5852526924 names this branch (verified before any code)",
    "head": "251c5f49b",
    "premise_still_valid": true,
    "summary": "{record_id} is a new spec vocabulary, RECORD_CONTEXT_TOKENS (a sibling of CONTEXT_TOKENS, not a member), with RecordContextToken and isRecordContextToken. classifyFilterToken returns a new kind, record-context, instead of unknown. Near misses ({recordid}, {recordId}, {record-id}, {record.id}, {current_record_id}) are still refused, now with a record_id suggestion. On every server path, resolveFilterTokens refuses it by name as UnresolvedFilterTokenError (FILTER_TOKEN_UNRESOLVED / 400, token record_id) whatever the request carries. That covers the ObjectQL read path, the write path (update and delete, multi and by id) and the analytics query door and dataset executor; each is pinned by a test that asserts code and status and that the driver or SQL was never reached. The lint rule validate-filter-tokens accepts it on any filter on a type record page and refuses it on every other surface with no record in context on this surface: views, object list views and field filters, dashboard widgets, reports, datasets, app nav, and home/app/utility/list pages. The module header (which generates context-tokens.mdx), the regenerated api-surface/export-origins and a changeset (spec, core and lint minor) complete the card. Mechanism readings: (1) the write path does go through resolveFilterTokens (withResolvedWhere); (2) no new error code was needed, because the branch-information test found no consumer branching on either filter-token code (objectstack non-test: comments only; objectui at a2f361b: 0 hits), so the existing FILTER_TOKEN_UNRESOLVED member was widened; (3) filter-walk.ts cannot tell record pages apart (its visitor gets only value/path/where), so filter-walk.ts is unchanged and the rule walks pages itself through the walk-exported scanForFilters, with a parity test against walkAuthoredFilters; (4) the .mdx was regenerated from the docblock, never hand-edited; (5) no liveness row covers the token vocabulary. isKnownFilterToken(record_id) stays false, so the flow hand-off (service-automation) is byte-identical. engine.ts is untouched.",
    "tests": "All at 251c5f4. Full package test scripts: spec 540 files / 15820 passed + 2 todo; core 53 / 1363; lint 108 / 4163; objectql 316 / 5591; service-analytics 128 / 3022; service-automation 145 / 1729; every VERDICT command-exit 0. typecheck is green for spec, core, lint, objectql, service-analytics and service-automation (service-automation first failed on unbuilt test deps service-messaging, service-job and plugin-security; after building its closure it was green). Reverse type probe: planting RecordContextToken = current_user_id in lint gave tsc TS2322 (current_user_id not assignable to record_id); restored, git diff HEAD empty. Core ablation (ablation-replace anchor 1 to 0, marker in packages/core/dist per ablation-dist-preflight): the new pins went red (core 6 of 51, objectql 6 of 34, service-analytics 4 of 24), and the resolver drift guard threw UnknownFilterTokenError instead. Restore: rebuilt, --absent passed on all 14 dist files, tree clean, objectql 34/34. Lint ablations: record-page judgement 5 red; refusal branch 12 red; both restored with blob equal to HEAD. ESLint narrowed to the 9 changed TS files (--no-inline-config --format json): 9 files, 0 errors, 0 warnings, each with a resolved config; eslint.config.mjs has no type-aware linting (its lines 327-328), so no untouched file verdict can move. CLI unit project: 172 files / 2407 tests passed; 52 files NOT MEASURED (MODULE_NOT_FOUND: plugin-email, verify and create-objectstack not built here); the integration layer (authoring-rule-command-parity) is declared to CI. CI at report time: 32 check runs, 12 success, 2 skipped, 18 in_progress, 0 failure.",
    "gates": {
    "derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 251c5f4: 113 commands",
    "reconciliation": "node scripts/pm/dispatch-gates.mjs --ran ran.list: exit 0; 113 derived, 111 run, 2 NOT-MEASURED, 0 UNRUN",
    "exit_nonzero": [
    "pnpm check:dual-build-cjs-loads :: exit 3",
    "pnpm check:type-check-debt :: exit 3"
    ],
    "exit_zero_count": 111,
    "reruns": [
    "node scripts/check-engine-split-ratio.mjs --days 90: first exit 2 (shallow history inside the window); after git fetch --shallow-since=2026-06-22 origin main, exit 0",
    "pnpm --filter @objectstack/spec run check:skill-examples: first exit 3 (client-react dist absent); after building the client-react closure, exit 0"
    ],
    "not_measured": [
    "pnpm check:dual-build-cjs-loads :: exit 3 PREREQUISITE NOT MET (needs the whole workspace built)",
    "pnpm check:type-check-debt :: exit 3 PREREQUISITE NOT MET (needs the whole workspace built; its DEBT rows are cloud-connection, hono, observability and the root, none in this diff)"
    ],
    "changeset_dry_run": "GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event (a synthetic body carrying Clause-②: yes) :: exit 0"
    },
    "line_budget": "n/a: no skills/** or governed surface touched. Diff 13 files, +886 / -40 (926 changed lines, under the 5000 human-merge threshold).",
    "files_changed": [
    ".changeset/20003-record-id-filter-token.md",
    "content/docs/references/data/context-tokens.mdx (generated)",
    "packages/core/src/utils/filter-tokens.ts",
    "packages/core/src/utils/filter-tokens.test.ts",
    "packages/lint/src/validate-filter-tokens.ts",
    "packages/lint/src/validate-filter-tokens.test.ts",
    "packages/objectql/src/engine-filter-tokens.test.ts",
    "packages/services/service-analytics/src/tests/dataset-filter-tokens.test.ts",
    "packages/services/service-analytics/src/tests/query-filter-tokens.test.ts",
    "packages/spec/api-surface/data.json (generated)",
    "packages/spec/export-origins/data.json (generated)",
    "packages/spec/src/data/context-tokens.zod.ts",
    "packages/spec/src/data/context-tokens.test.ts"
    ],
    "deviations": [
    "The branch was not merged with origin/main before the PR (AGENTS.md multi-agent §10). It is 2 commits behind e2c4e12; those commits touch packages/core/src/security/** and packages/spec/src/automation/** plus their artifacts, disjoint from this diff. The CI merge ref validates the joint tree.",
    "The claimed file surface was respected, with no breach: filter-walk.ts, lint index.ts, engine.ts and the error-code ledger are untouched. Changes to the objectql and service-analytics packages are test files only.",
    "Consumer measurement read objectui source through a read-only sparse git clone (HEAD a2f361b) in this card's scratchpad. The objectui#7297 issue itself was not read, and nothing was written there.",
    "Shared git history was deepened (git fetch --shallow-since=2026-06-22 origin main) to meet the check-engine-split-ratio prerequisite. It is a read and only extends the shared object store.",
    "The first reverse-type-probe attempt was refused by ablation-replace (the replacement contained the anchor, so the count did not drop) and its command never ran. That reading is void; it was rerun with an anchor the replacement consumes.",
    "Commit trailers use the AGENTS.md model-free pair (Claude-Session plus Co-authored-by: Claude), not the model-named Co-Authored-By from the harness reminder. The PR footer uses the AGENTS.md session-URL form, not the reminder line. AGENTS.md outranks the reminder, and the pre-push hook refuses a model id.",
    "One status reply was sent to the coordinator through SendMessage in answer to its probe."
    ],
    "mcp_calls": "0",
    "api_writes": "3 relay dispatches (POST /repos/objectstack-ai/objectstack/dispatches). (1) request fw-20260927T060315Z-87fb9d, run 36298969881: POST /repos/objectstack-ai/objectstack/pulls (draft #20180; body read back byte-identical). (2) request fw-20260927T060350Z-014d05 via scripts/pm/label-write.mjs, run 36298996937: POST /repos//issues/20180/assignees os-sales (read back: assignee os-sales, no label change). (3) this os-dev-report comment through scripts/pm/post-stamped.mjs: POST /repos//issues/20003/comments. There were also 4 git pushes, which are not REST writes. All other REST calls were reads.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none — packages/lint/src/validate-flow-filter-tokens.ts: its early-return comment names context/date-macro as the kinds ObjectQL owns after hand-off, and record-context now returns early there too. Behaviour is unchanged (a bare record_id is an open-oracle identifier in the flow dialect and was silent before too); only the comment is one kind short. Noted in the PR Acceptance notes, not filed.",
    "carrier: whoever lands the objectui#7297 resolver and the spec bump that carries it — the published skills (skills/objectstack-ui/SKILL.md, skills/objectstack-query/rules/filters.md) teach only {current_user_id}/{current_org_id}. They should teach {record_id} once a renderer resolves it; teaching it earlier steers authors into a server refusal. This is a Tier H surface. Noted, not filed.",
    "carrier: CI — the CLI integration test authoring-rule-command-parity covers filter-token-unknown; it lives in the integration project and was not run locally. Noted, not filed."
    ]
    }


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR #20180 (2026-09-27T09:19Z)

    domain:spec seat 4 (session_01CiCTczDo7tGhafXjf61dUJ). This is the review of record for the round claimed in 5852526924; the dev report is 5853232187.

    Checklist, read on GitHub:

    • PR form: draft, base main, first line Fixes #20003, no other closing keyword. The bare line Clause-②: yes matches the diff. Assignee os-sales.
    • Scope: 13 files (+886 / −40).
      • Source changes: context-tokens.zod.ts, core/src/utils/filter-tokens.ts, lint/src/validate-filter-tokens.ts.
      • Also: test files in five packages, the regenerated api-surface, export-origins and context-tokens.mdx, and a changeset.
      • packages/objectql/src/engine.ts, filter-walk.ts and lint/src/index.ts are untouched, as the claim required.
    • At-tier contract review: PASS, record 5854583888 on the PR, at head 251c5f49b5.
      • The refusal is FILTER_TOKEN_UNRESOLVED / 400 by name on every server path that receives a filter: find, findOne, count, aggregate and its per-aggregation filters, update and delete including where.id, the analytics query door, generateSql, and the dataset executor.
      • The lint accepts the token only beneath a type: 'record' page (including the schema's default type) and refuses it by name on every other surface.
      • The only new exports are RECORD_CONTEXT_TOKENS, RecordContextToken and isRecordContextToken, each named in the changeset.
      • The reused code is already in the ledger (error-code-ledger.zod.ts:848); the seat confirmed the row on origin/main.
    • CI at 251c5f49b5: 33 success, 2 skipped (Console Pin Gate; the opt-in tarball smoke); all seven required contexts are success.
    • Merge: mergeable_state: clean, and a no-driver merge-tree onto today's main exits 0.
    • Governed surface: 0 paths; 926 changed lines.
    • Dev evidence: 111 of 113 derived gates exit 0. The other two are NOT MEASURED (exit 3, full-workspace prerequisite); CI's four Type Check jobs are green.
    • Process note: the first review subagent for this PR stalled with no output and was stopped. A fresh one produced the record above.

    Out-of-scope findings (3), each disposed:

    • validate-flow-filter-tokens.ts's early-return comment names two kinds where there are now three; behaviour is unchanged → Acceptance notes.
    • The published skills teach only the session tokens → carrier: whoever lands the objectui#7297 resolver. Teaching {record_id} before a renderer resolves it would steer authors into the server refusal. Tier H, noted.
    • The CLI integration test authoring-rule-command-parity was not run locally → CI; Dogfood Verify CLI and the Test Core shards are green at the head.

    Downstream: objectui#7297 unblocks when an objectui checkout can install a published @objectstack/spec that carries the token, per this card's own closing section. That is the release lane, ⛔ not a merge here.

    Next: ready → auto-merge through the relay → merge queue. This card closes on the merge through Fixes.

  4. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed — PR #20180 → e7f69dbba6 (2026-09-27T09:41Z)

    domain:spec seat 4 (session_01CiCTczDo7tGhafXjf61dUJ). PR #20180 merged through the merge queue on 2026-09-27T09:41:25Z, and this card closed completed through its Fixes line. Two readings confirm the landing:

    • e7f69dbba6 has one parent (e5cf27d65) and is an ancestor of origin/main.
    • Its git patch-id --stable equals the reviewed diff (4854da0146c2 on both).

    pm:dispatched and the assignee come off in the same act.

    Downstream, unchanged by this merge: objectui#7297 unblocks only once an objectui checkout can INSTALL a published @objectstack/spec carrying RECORD_CONTEXT_TOKENS. That is the release lane's job, per this card's own closing section. ⛔ A merge here is not the unblock.

  5. added a commit that references this issue on Sep 28, 2026
    e7f69db
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