Repository navigation
Commit b861525
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi95v1bE93v4RhtAn6F7LSiDY)_
Fixes #29460
## 维护者速读
- 改了什么:声明了 `referenceNameVisible: true` 的 lookup / user 字段,在 `$expand`
时,读者被拒读的目标记录现在答 `{ id, name, readRefused: true }`;此前它只答裸 id。
- 为什么改:这个键由 #29171 的裁决(6574398310,字母 B)声明在前、运行时未供;本卡兑现它,ui 卡 #29462
照这个形状把名字渲染成不带链接的纯文本。
- 风险与回滚:这是放宽读面(向被拒读者多给一个名字)。名字由引擎只按 id 与名称列去读,调用方的投影、嵌套
expand、过滤和排序都不进入那次读;名称列上的字段级规则(FLS、maskingRule、经引用的公式)照旧生效,此时给 `Record
#<id>` 底名。不动存储形态,回滚即 revert 本 PR。
- 先看:`packages/objectql/src/engine-reference-name-visible.ts`
的两次读各自拿到什么,再看 `packages/rest/src/data-reference-name-visible.test.ts`
里的负向钉子与对照组。
- 两分钟验证:`pnpm --filter @objectstack/objectql build`,再在 `packages/rest` 下
`pnpm exec vitest run src/data-reference-name-visible.test.ts`,23 个全绿。
- 需要你做的:裁定下文 Open questions 第 1 条(名称列对读者 FLS 隐藏时,本 PR 给底名 `Record
#<id>`,还是照给名字),其余无。
## What changes
`FieldSchema.referenceNameVisible` was declared ahead of its runtime. It
is now served by objectql's `$expand`:
- **A target the reader is refused** (the target object's read gate, its
required permissions, or its row scope) answers `{ id, name,
readRefused: true }` in place of the bare id. `id` is the id the
referencing row holds. `name` is the target's display name as ADR-0079
resolves it (`nameField`, then `displayNameField`, then the derived
title, with the floor `Record #<id>`). `readRefused: true` says in the
value that the reader was refused the target (the `metadataRefused`
precedent of a refused file value), so a renderer shows the name as text
and links nothing. A `multiple: true` field is answered per element.
- **Nothing else of the target.** Two reads, each handed only what it
needs:
1. which of the ids the caller's own expansion left bare are refused is
asked **as the caller**: the security service's object-level admission,
then an id-only read for the row scope. None of the caller's `$expand`
nesting is forwarded.
2. the name is an engine-owned read of the refused ids only, projected
to `id` and the resolved name field, under the tenant-kept elevation (`{
...caller, isSystem: true }` with operation-private keys stripped, so
the driver still scopes it to the caller's organization). The `$expand`
request's projection, nested expand, filter and order never reach it,
and the field's own `displayField` does not choose the column it reads.
- **A field-level rule on the name field is not lifted.** When the
caller's field answer (`getReadableFields`) does not hold the name
field, the field declares a `maskingRule`, it is a formula reading
through another reference, it is not a title-typed column, or no field
answer can be had, the name is the `Record #<id>` floor. See Open
questions 1.
- **Unchanged:** a target the reader can read is expanded exactly as
before; an id no record has, a record of another organization, and every
field without the key keep the bare id; an unexpanded read answers the
stored bare id for every reader (ADR-0104 D1). `sys_user` names through
a `user` field or a lookup to `sys_user` take the same path; there is no
separate carve-out.
- The key's `.describe()` drops its `[PLANNED — not yet served]` marker
and states what is served; `packages/spec/liveness/field.json` records
the key `live`, citing the server test; generated reference docs and the
liveness counts are regenerated.
Code: `packages/objectql/src/engine-reference-name-visible.ts` (new; the
module-split host pattern, engine imported as a type only), one call
site and one private method in
`packages/objectql/src/engine.ts#expandRelatedRecords`, and
`isReadRefusal` exported from `engine-file-reference-hydration.ts` so
the two sub-reads that serve a refusal in the value read one predicate.
### Response shape for the ui card (#29462)
After `$expand` of a `referenceNameVisible` field, each value is one of:
| the reader and the target | the value |
| --- | --- |
| may read the target | the expanded record, exactly as without the key
|
| refused the target (gate, required permissions, row scope) | `{ id,
name, readRefused: true }`; `name` may be the floor `Record #<id>` |
| the id names no record, or a record of another organization | the bare
id |
| no key on the field, or no `$expand` | the bare id |
## Why
Ruling 6574398310 (letter B) on #29171: a principal who can read the
record carrying the field may see the referenced record's name and id
even when it cannot read that record, and nothing else of it. The ui
lane renders it (#29462).
### Readings this card starts from (`main` at a922779, real ObjectQL
over SQLite, REST server)
- Plain engine read, REST `GET` by id and REST list (no `$expand`): the
lookup holds the bare FK id for every reader (readable,
row-scope-hidden, gate-refused, dangling).
- `$expand`: a readable target is the full record; a row-scope-hidden or
gate-refused target, or a dangling id, keeps the bare id. A `sys_user`
the reader's row scope admits is expanded; one it does not keeps the
bare id.
- The card's "no name and no id" is the ui's placeholder: its own
follow-up read of the target is refused. No `sys_user` name carve-out
exists in the engine or the plugins.
## Verification
Clause-②: yes (widening)
Metadata-protocol: none
_Keep one value on each of the two lines above, each line starting with
its key.
[Human
Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum)
item 5 says what they mean._
**Tests** — `packages/rest/src/data-reference-name-visible.test.ts`
(new, 23 tests): real ObjectQL, the SQLite driver, the security plugin
and the REST server; the record, list and query doors; a single lookup,
a `multiple: true` lookup, a `user` field and a lookup to `sys_user`;
the row-scope, gate and cross-organization cases; the floors; and a
control for every negative pin.
- Red on the old build, green on the new: before the engine change, the
suite as it then stood read 13 failed, 8 passed, 1 skipped of 22 (every
served case red, every control green); on this head 23/23.
- `packages/spec` field and liveness suites: the field,
field-reference-name-visible and liveness suites 3 files, 181 passed; on
this head the package suites read objectql 534/534 files, 10391 tests
passed; rest 374/374 files, 7080 passed, 332 skipped; spec 792/792
files, 23522 passed, 1 todo.
**Forced test edit:**
`packages/spec/src/data/field-reference-name-visible.test.ts` pinned the
`[PLANNED` marker in the describe. It now pins the marker's absence and
the served shape (the designed flip of a declared-ahead key); the bounds
pins are unchanged.
**Ablations** (each planted with `scripts/ablation-replace.mjs`,
objectql rebuilt, presence proven in `dist/` with
`scripts/ablation-dist-preflight.mjs`, the suite run, the source
restored and proven equal to HEAD; after the last leg every marker
proven absent from `dist/`, the tree clean, the suite 23/23):
| leg | forbidden behaviour put back | red |
| --- | --- | --- |
| 1 | the caller's `$expand` projection, nested expand and nested filter
and the field's `displayField` forwarded into the name read, and its row
spread into the value | 15 failed (the nested projection, nested expand
and `displayField` pins among them) |
| 2 | the key gate removed (every reference field served) | 2 failed
(the without-key controls) |
| 3 | the caller's field answer on the name field ignored | 1 failed
(the FLS floor) |
| 4 | the name field's `maskingRule` ignored | 1 failed (the masking
floor) |
| 5 | a formula name reading through another reference computed under
the elevation | 2 failed (the formula floor, the nested-expand pin) |
| 6 | the name read loses the caller's organization | 1 failed (the
other-organization pin) |
| 7 | names served on the unexpanded read too | 1 failed (the
unexpanded-read pin) |
| 8 | a name invented for an id the name read did not return | 4 failed
(dangling, per-element, list, other-organization) |
| 9 | the caller probe's readable answer dropped | 1 failed (the
readable-target-own-filter control) |
**Local gates** on `7e8a7b30`: `dispatch-gates --commands` derived 109
commands after the last commit; 109 run, every one exit 0; `--ran`
reconcile 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN. `pnpm lint`
exit 0; `typecheck` of objectql, rest and spec exit 0.
**Dogfood battery** on `7e8a7b30`: `os-verify-lock` VERDICT command-exit
0 (held 29m15s, shared box) · Test Files 397 passed, 3 skipped (400) ·
Tests 3277 passed, 28 skipped (3305) · no failures
Contract review: the paths put this on the schema face
(`record-recognisers.mjs --paths`), at `CONTRACT_REVIEW_TIER`, scope
`packages/spec/src/data/field.zod.ts`.
## Open questions
1. **The name field is itself kept from the reader by a field-level
rule.** The describe as declared did not mention field-level rules;
serving the name there would bypass the caller's field grant (or compute
a mask or a reference hop under the elevation). Options: **A** (this PR)
the name is the floor `Record #<id>` with the id, and the describe says
so; **B** serve the name regardless, and amend the describe to say the
key lifts field-level rules on the name field too (drop three checks in
`servableNameField`). Recommendation A: it serves the ruling's "name and
id" without widening any field grant, and B stays a contained follow-up
if ruled. A reader who can read the target row but not its name field is
served exactly what it is served without the key (the ruling's "can read
the target ⇒ as today").
2. **Plain reads are not served.** The delta asked to measure the plain
read; ADR-0104 D1 has every unexpanded reference answer its stored form
(the bare id, which is already the "id" half). Serving a name there
would give a refused reader a richer value than a reader of the target
gets on the same read. Served on `$expand` only; a ui that wants the
name expands the field.
3. **A refused target is named even when the caller's own nested filter
would have excluded it.** Filters are never forwarded into the name
read, by design (forwarding them is ablation 1). A readable target the
caller's nested filter excludes keeps today's bare id (control pinned).
4. **A named Zod schema for the served value** (`{ id, name,
readRefused: true }` beside `FileRefusedValueSchema` in
`field-value.zod.ts`) is spec-lane follow-up; this PR exports the
TypeScript shape `ReferenceNameOnlyValue` from the engine module only.
## Human Minimum
Each item is one rule from
[CONTRIBUTING.md › Human
Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum),
which links the AGENTS.md text that states it.
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the 维护者速读 block above
is Chinese by the lane seat's standing order).
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmRXbaXBLCJeJXfoh21)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2420778 commit b861525
13 files changed
Lines changed: 881 additions & 35 deletions
File tree
- .changeset
- content/docs/references
- data
- system
- packages
- core/src/security
- objectql/src
- rest/src
- spec
- liveness
- state-counts
- 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
78 | 78 | | |
79 | 79 | | |
80 | 80 | | |
81 | | - | |
| 81 | + | |
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
247 | 247 | | |
248 | 248 | | |
249 | 249 | | |
250 | | - | |
| 250 | + | |
251 | 251 | | |
252 | 252 | | |
253 | 253 | | |
| |||
618 | 618 | | |
619 | 619 | | |
620 | 620 | | |
621 | | - | |
| 621 | + | |
622 | 622 | | |
623 | 623 | | |
624 | 624 | | |
| |||
0 commit comments