Repository navigation
Commit e7f69db
feat(spec): a record-scoped filter token {record_id}, refused by name where no record is in context (#20180)
Fixes #20003
Clause-②: yes
## What this does
Adds `{record_id}`, a filter token for the id of the record a `type:
'record'` page is showing. It is the spec half of objectui#7297; the
renderer's resolver is that card's work.
- **Recognised by name.** `classifyFilterToken('{record_id}')` returns a
new kind, `{ kind: 'record-context', token: 'record_id' }`, instead of
`unknown`.
- **Refused by name on every server path.** `resolveFilterTokens()`
throws `UnresolvedFilterTokenError` (`FILTER_TOKEN_UNRESOLVED` / 400,
`token: 'record_id'`) whatever the request carries. The token never
becomes `null`, `undefined` or the literal.
- **Allowed by lint only where a record is in context.**
`validate-filter-tokens` accepts it in any filter on a `type: 'record'`
page. It refuses it everywhere else with "no record in context on this
surface".
- **Documented** in the module header, which generates
`content/docs/references/data/context-tokens.mdx`. The header says where
the token is honoured and separates it from `{recordId}` and the
`'record_id'` page-variable type.
Maintainer ruling, verbatim (quoted in the card): 「需要我决定的 6 件事: 6947
7300 不做;其他同意」. objectui#7297 is among 「其他」, so the ruled shape is: do
`{record_id}`; with no record context it fails with a clear error, never
an empty value.
## Decisions taken, with the measurement behind each
**1. A sibling list (`RECORD_CONTEXT_TOKENS`), not a member of
`CONTEXT_TOKENS`.** The card left this open, so I measured every
consumer of `CONTEXT_TOKENS` / `isContextToken` /
`CONTEXT_TOKEN_SUGGESTIONS`:
- objectstack at merge base `84880f9`: two readers.
- The lint hint's token list (`validate-filter-tokens.ts`): joining
would advertise `{record_id}` on the surfaces that refuse it.
- `ContextTokenSchema` / `ContextTokenPlaceholderSchema`: joining would
accept it on every surface.
- objectui at `a2f361b` (`packages/*/src`, `apps/*/src`): one reader,
`packages/core/src/utils/filter-tokens.ts` (`resolveContextTokens`). It
fills a session map pinned by a `satisfies` clause: a `Record` keyed by
`ContextTokenName`. Joining would red objectui's typecheck on the spec
bump. At runtime, `{record_id}` would resolve to `undefined` with a "no
active organization" warning.
With the sibling list, objectui's current code passes `{record_id}`
through untouched and the server refuses it by name, which is loud.
objectui#7297 then adds the surface-scoped resolver as its own thing.
The kind is separate for the same reason: the verdict depends on where
the token is written, so a caller with no record refuses it by name
instead of calling it `unknown`.
**2. Refusal code: reuse `FILTER_TOKEN_UNRESOLVED`; no new code and no
ledger entry.** I applied the branch-information test ("Does this defect
give a consumer a branch it could act on differently? No ⇒ widen the
existing member's comment/message"):
- No consumer branches on either filter-token code. In objectstack, the
only non-test mentions are comments. In objectui at `a2f361b` there are
zero hits for `FILTER_TOKEN_UNRESOLVED`, `FILTER_TOKEN_UNKNOWN` or
`UnresolvedFilterToken`.
- The condition is the existing member's own definition: "a vocabulary
token the request carries no value for". The error's `token` field
already tells which context is missing.
So I widened `UnresolvedFilterTokenError`'s docblock and gave it a
record-specific detail message. `read-scope-sql.ts`'s statement "Today
that is its two refusals" stays true.
**3. `filter-walk.ts` unchanged.** The walk hands its visitor `{ value,
path, where }` and nothing about the item a filter came from, so it
cannot tell a record page from any other page. The rule now walks the
`pages` collection itself, page by page, through `scanForFilters`, which
the walk already exports for "a caller that already has a single item in
hand". The page's `type` is then in the closure. The other six surfaces
still go through `walkAuthoredFilters`, one surface per call and in the
declared order, so findings keep the order they had. A test holds the
page loop's `path` and `where` equal to the walk's, including a
map-shaped `pages` collection and an unnamed page. An absent `type` is
judged as `PageSchema`'s declared default `'record'`; the rule runs on
parsed input, where the default is already applied. A test pins that
default against `PageSchema.parse`.
**4. Lint rule id: `filter-token-unknown`, message widened.** The
branch-information test from decision 2 gives the same answer here: a
consumer keyed on the id acts the same way (this filter cannot run where
it is written), and the message and hint say which of the two causes
fired. The refusal text says "no record in context on this surface" and
names the surface. For a page it gives the page type, e.g. `a page of
type "list"`. It does not say "is not a resolvable placeholder". No new
constant, so `packages/lint/src/index.ts` is untouched.
**5. `isKnownFilterToken('record_id')` stays `false`.** Its one consumer
is the flow engine's filter hand-off (`interpolateFilter`), a server
position. There, an unset `{record_id}` keeps being the flow's own
unresolved-variable refusal (the CRUD guard) instead of being handed to
ObjectQL. `service-automation` is untouched and its behaviour is
byte-identical.
**6. The write path goes through the resolver, measured.**
`update`/`delete` call `withResolvedWhere` (`engine.ts` update and
delete paths), which calls `resolveFilterTokens`. The refusal pins below
cover `update(multi)`, `delete(multi)` and update by `where.id`. None
needed an engine change. `engine.ts` is untouched; the `domain:engine`
claim on #20148 holds it.
**7. Liveness ledger:** no row under `packages/spec/liveness/` covers
the token vocabulary. The one mention is a navigation `recordId` note in
`app.json`, which is a different mechanism.
## Compile surfaces: one line each
Measured by grepping each file for the vocabulary
(`classifyFilterToken`, `isContextToken`, `CONTEXT_TOKENS`,
`isKnownFilterToken`, `resolveFilterToken(s)`, the wrapped-token
regexes) at `251c5f49b`.
- driver-sql `applyFilterCondition` (`sql-driver.ts`; sqlite-wasm and
turso local mode inherit it): **already compliant** (0 vocabulary refs).
It sits downstream of the engine's `resolveWhereTokens` /
`withResolvedWhere`, which now refuse `{record_id}` before the driver.
- turso RemoteTransport `buildWhereSQL` (`remote-transport.ts`):
**already compliant** (0 refs), for the same reason: it is a driver
reached only after the engine resolves. Output is unchanged.
- service-analytics `compileScopedFilterToSql` (`read-scope-sql.ts`):
**changed only in the wrapped cause.** It resolves the scope with
`resolveFilterTokens` (2 refs) and re-raises as
`READ_SCOPE_COMPILE_FAILED` / 500. For a scope carrying `{record_id}`,
the embedded resolver message is now the UNRESOLVED one instead of
UNKNOWN. The envelope is unchanged.
- service-analytics `lowerAnalyticsWhere` (`filter-normalizer.ts`):
**already compliant** (0 refs). The analytics doors resolve the query's
`where` first (`analytics-service.ts`), and those now refuse
`{record_id}`. Pinned on the direct query door and on the dataset
executor's three positions.
- formula `matchesFilterCondition` (`matches-filter.ts`): **explicitly
out of scope** (0 refs). It never consults the vocabulary, so this PR
moves no byte of its output. A literal `{record_id}` reaching it
compares exactly as before.
- objectql having-filter `applyHaving` / `matchesHaving`
(`having-filter.ts`): **explicitly out of scope** (0 refs). `having` is
not a token position on this tree: `resolveWhereTokens` resolves `where`
and `aggregations[].filter` only. This behaviour is unchanged for every
token, not only `{record_id}`.
- driver-memory `checkCondition` (`memory-matcher.ts`): **already
compliant** (0 refs); a driver downstream of the engine's refusal.
- driver-mongodb `translateFieldOperators` (`mongodb-filter.ts`):
**already compliant** (0 refs); a driver downstream of the engine's
refusal.
## Changeset
`.changeset/20003-record-id-filter-token.md`: `@objectstack/spec`,
`@objectstack/core` and `@objectstack/lint` are each `minor`. It lists
the surfaces that now accept the token and the ones that refuse it, per
the card's rule 5. objectql and service-analytics move test files only.
I dry-ran `check-changeset-no-major` with a synthetic event carrying
this body's `Clause-②: yes` line; it answered exit 0 ("no package whose
`packages/**/src/**` it moves is graded `patch`").
## Evidence (all at `251c5f49b`)
Each package's full `test` script:
| Package | Files | Tests |
|---|---|---|
| spec | 540 | 15820 passed, 2 todo |
| core | 53 | 1363 |
| lint | 108 | 4163 |
| objectql | 316 | 5591 |
| service-analytics | 128 | 3022 |
| service-automation | 145 | 1729 |
- **Typecheck:** `typecheck` is green for spec, core, lint, objectql,
service-analytics and service-automation.
- **Reverse type probe.** I planted `const __probe: RecordContextToken =
'current_user_id'` in lint, and `tsc` went red with `TS2322: Type
'"current_user_id"' is not assignable to type '"record_id"'`. Restored
with `git diff HEAD` empty. This shows lint reads the rebuilt spec
`.d.ts`.
- **Ablation, core** (via `scripts/ablation-replace.mjs`, marker proved
in `packages/core/dist` by `ablation-dist-preflight`). I made the
record-context branch in `resolveFilterToken` unreachable. The new pins
went red: core 6, objectql 6, service-analytics 4. The other tests
stayed green: 45, 28 and 20. With the branch gone, the resolver's drift
guard throws `UnknownFilterTokenError` instead. Restore leg: rebuilt;
`--absent` found the marker in none of 14 dist files; tree clean;
objectql 34/34 green again.
- **Ablation, lint.** With the record-page judgement ablated, the 5
acceptance pins went red. With the refusal branch ablated, the 12
refusal pins went red. Both restored, blob equal to HEAD.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 113. `--ran` reconciliation: 113
accounted for, 111 run with exit 0, 0 unrun, and 2 NOT MEASURED (exit 3,
PREREQUISITE NOT MET: both need the whole workspace built):
- `check:dual-build-cjs-loads`
- `check:type-check-debt`, whose DEBT rows are cloud-connection, hono,
observability and the root. None of those is in this diff.
Two first runs refused on a prerequisite; each was satisfied and re-run
to exit 0:
- `check-engine-split-ratio` needed history deepened past the window
(`--shallow-since=2026-06-22`).
- `check:skill-examples` needed `client-react` built.
- **ESLint, narrowed to the 9 changed TS files** (`--no-inline-config
--format json`): 9 files, 0 errors, 0 warnings, and each file resolves a
config. Invariance: `eslint.config.mjs` enables no type-aware linting
(no `parserOptions.project`, stated at its lines 327–328), so this diff
cannot move a verdict on an untouched file.
- **Base:** the branch sits 2 commits behind `origin/main` (`e2c4e125`).
Those commits touch `packages/core/src/security/**`,
`packages/spec/src/automation/**` and their artifacts, which is disjoint
from this diff. CI's merge ref validates the joint tree.
## Acceptance notes (not filed)
- `packages/lint/src/validate-flow-filter-tokens.ts` has an early-return
comment that names `context` / `date-macro` as the kinds ObjectQL owns
after the hand-off. `record-context` now also returns early there.
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. Carrier: none (outside this card's claimed surface).
- The published skills teach only `{current_user_id}` /
`{current_org_id}` (`skills/objectstack-ui/SKILL.md`,
`skills/objectstack-query/rules/filters.md`). They should teach
`{record_id}` once a renderer resolves it. Teaching it before then would
steer authors into a server refusal. `skills/**` is a Tier H surface.
Carrier: whoever lands objectui#7297's resolver and the spec bump that
carries it.
- `packages/cli/test/authoring-rule-command-parity.test.ts` lives in the
CLI's `integration` project and is left to CI. The CLI's `unit` project
here ran 172 files and 2407 tests green. Its other 52 files did not
load, because `plugin-email`, `verify` and `create-objectstack` are not
built in this container (MODULE_NOT_FOUND), so they are NOT MEASURED.
## Not in this PR
- The objectui resolver (objectui#7297).
- Any token other than `{record_id}`.
- RLS `current_user.*`.
- `AppContextSelector` ids.
- `titleFormat` interpolation.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent e5cf27d commit e7f69db
13 files changed
Lines changed: 886 additions & 40 deletions
File tree
- .changeset
- content/docs/references/data
- packages
- core/src/utils
- lint/src
- objectql/src
- services/service-analytics/src/__tests__
- spec
- api-surface
- export-origins
- src/data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
9 | | - | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
10 | 21 | | |
11 | 22 | | |
12 | 23 | | |
| |||
38 | 49 | | |
39 | 50 | | |
40 | 51 | | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
41 | 63 | | |
42 | 64 | | |
43 | 65 | | |
44 | 66 | | |
45 | | - | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
46 | 70 | | |
47 | 71 | | |
48 | 72 | | |
| |||
62 | 86 | | |
63 | 87 | | |
64 | 88 | | |
65 | | - | |
66 | | - | |
67 | | - | |
68 | | - | |
69 | | - | |
70 | | - | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
71 | 121 | | |
72 | 122 | | |
73 | 123 | | |
74 | 124 | | |
75 | 125 | | |
76 | 126 | | |
77 | 127 | | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
78 | 131 | | |
79 | 132 | | |
80 | 133 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
145 | 145 | | |
146 | 146 | | |
147 | 147 | | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
148 | 214 | | |
149 | 215 | | |
150 | 216 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
75 | 86 | | |
76 | 87 | | |
77 | 88 | | |
78 | 89 | | |
| 90 | + | |
79 | 91 | | |
80 | 92 | | |
81 | 93 | | |
| |||
143 | 155 | | |
144 | 156 | | |
145 | 157 | | |
146 | | - | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
147 | 161 | | |
148 | 162 | | |
149 | | - | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
150 | 167 | | |
151 | 168 | | |
152 | 169 | | |
| |||
320 | 337 | | |
321 | 338 | | |
322 | 339 | | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
323 | 357 | | |
324 | 358 | | |
325 | 359 | | |
| |||
0 commit comments