Skip to content

feat(objectql): a referenceNameVisible lookup or user field names a target the reader is refused, on $expand (#29460) - #29576

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-jdgmh0
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-jdgmh0

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #29460

维护者速读

  • 改了什么:声明了 referenceNameVisible: true 的 lookup / user 字段,在 $expand 时,读者被拒读的目标记录现在答 { id, name, readRefused: true };此前它只答裸 id。
  • 为什么改:这个键由 Portal UX: let a lookup show the target's name when the viewer cannot read the target (decision) #29171 的裁决(6574398310,字母 B)声明在前、运行时未供;本卡兑现它,ui 卡 ui: render a referenceNameVisible lookup's name as plain text, and add its Studio form row (#29171 ruling B) #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
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,
which links the AGENTS.md text that states it.

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block above is Chinese by the lane seat's standing order).
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…et by name and id on $expand

A lookup or user field declaring referenceNameVisible answers
{ id, name, readRefused: true } on $expand for a target the reader is
refused (object gate or row scope); readable targets, dangling ids and
fields without the key keep today's answers.

Claude-Session: https://claude.ai/code/session_01ADHkmRXbaXBLCJeJXfoh21
Co-authored-by: Claude <noreply@anthropic.com>
… record the key live

The describe states the served value and the field-level floors; the
liveness ledger row cites the engine module and its real-driver test.
Reference docs and liveness counts regenerated. Changeset added.

Claude-Session: https://claude.ai/code/session_01ADHkmRXbaXBLCJeJXfoh21
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 14 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/liveness/field.json, packages/spec/liveness/state-counts/field.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-driven.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/external-datasources.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx (via FieldSchema (symbol, a top-level const))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-1.mdx (via FieldSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/liveness/field.json, packages/spec/liveness/state-counts/field.md) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 79 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 170 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b16ee8ba069d00bc00d01295869c1036fc9ea2e2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c5495da519080862348f13a8ab4a7eb19f9ad429 — the merge of head 36a4f3f758992e8d830376947ae4f1b734ff281d into base b16ee8ba069d00bc00d01295869c1036fc9ea2e2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c5495da519080862348f13a8ab4a7eb19f9ad429 && git checkout c5495da519080862348f13a8ab4a7eb19f9ad429
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b16ee8ba069d00bc00d01295869c1036fc9ea2e2 36a4f3f758992e8d830376947ae4f1b734ff281d && git checkout -B drift-repro b16ee8ba069d00bc00d01295869c1036fc9ea2e2 && git merge --no-ff 36a4f3f758992e8d830376947ae4f1b734ff281d

node scripts/docs-audit/affected-docs.mjs --json b16ee8ba069d00bc00d01295869c1036fc9ea2e2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b16ee8ba069d00bc00d01295869c1036fc9ea2e2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

Contract-review-claim: session_01ADHkmQJdgarUjPLNgFBY67 · head 7e8a7b3 · 2026-11-02T19:39Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7e8a7b30a46677129ad483194fb982a74f36e85b
Local-runs: none
Scope: packages/spec/src/data/field.zod.ts + packages/objectql/src/engine-reference-name-visible.ts, packages/objectql/src/engine.ts (the expandRelatedRecords hunk and serveReferenceNames), packages/objectql/src/engine-file-reference-hydration.ts (isReadRefusal), packages/objectql/src/engine-driver-options.ts and packages/core/src/security/tenant-layer0-verdict.ts (read for the elevated read's tenant scope), packages/plugins/plugin-security/src/security-plugin.ts (getReadableFields, canReadObject, the maskingRule source), packages/spec/src/data/display-name.ts and field-value.zod.ts (read for the resolver and the expanded value form), packages/spec/liveness/field.json, packages/spec/src/data/field-reference-name-visible.test.ts, packages/rest/src/data-reference-name-visible.test.ts

Inputs: card #29460 (body, triage 6581850357, claim 6583606476, dev report 6586656992), PR #29576 body and file list, git diff origin/main...pr29576 at the head above, ruling 6574398310 on #29171 (the ruling the describe cites), ADR-0079 D4 to D7 and ADR-0104 D1 in the tree, and the check-runs on the head. The rest of the diff (generated mdx, liveness counts, changeset prose, the REST test as a file) is the lane seat's ACCEPT at TIER_DEFAULT.

① Derived judgments

  • The served value. On $expand of a lookup or user field with referenceNameVisible: true, an id the caller's own expansion left bare and the caller is refused becomes { id, name, readRefused: true }; on multiple: true each element is judged alone (bareIdsOf collects per element, valueFor maps per element). RIGHT. The third key readRefused is not data of the target: it is the in-band refusal marker on the FileRefusedValueSchema precedent (metadataRefused), and without it a renderer could not tell a refused target from a readable one expanded with a narrow projection. It is inside the ruling's "name and id, nothing else of the target".
  • What the name read can reach. The name read is find(referenceObject, { where: { id: { $in: refused } }, fields: nameField ? ['id', nameField] : ['id'], context: { ...caller, isSystem: true } }), caller stripped of __ keys; the resolver is fed a shown object built from exactly those two keys. The caller's $expand projection, nested expand, nested where and orderBy are never read by the module (it takes records, fieldName, referenceObject, execCtx only), and displayField is never consulted (resolveDisplayField(targetSchema) picks the column). RIGHT: nothing beyond the id and the one name column can appear in the value.
  • Other tenants. The elevation keeps tenantId, and buildDriverOptions scopes an isSystem read by driverReadScope on the context's organization (isolated: one organization; group: the membership union; single: no wall, one organization). A target of another organization is not returned by the name read, so it stays the bare id, pinned under the isolated posture. RIGHT. One reading the record must carry: sys_user carries no organization_id, so Layer 0 contributes nothing there and the only scope the elevated read lifts on sys_user is the Layer 1 row scope (org_user_ids). That is exactly what ruling B asks for sys_user names (the stranger pins), so RIGHT, but the sentence "a record of another organization keeps the bare id" is true of tenant objects, not of sys_user rows; the describe makes no such claim, so no finding on the hits file.
  • A row the reader may not know exists. The id is already in the record the reader can read; the ruling itself serves existence plus name for a refused target. RIGHT by the ruling, and nothing more than existence and the one name leaks.
  • A name column kept by FLS / maskingRule / formula hop. servableNameField answers undefined (the floor Record # plus id) when the caller's getReadableFields answer lacks the column, when def.maskingRule != null (the security plugin sources masks from fdef.maskingRule only, so this is the whole mask vocabulary), when the formula planner reports a hop or cannot compile the expression, when the column is not title-typed or autonumber, or when no security service is usable. RIGHT: no field grant is bypassed and no mask or hop is computed under the elevation. A formula over the row's own columns (no hop) is computed under the elevation and served; that is the author-designated name itself and the same value a reader of the row gets today, so RIGHT.
  • Readable targets, dangling ids, other-org records, keyless fields, unexpanded reads: unchanged. Readable targets are already objects after the caller's expansion and never enter bare; a readable id the caller's own nested filter excluded is found readable by the id-only probe and stays bare (control pinned). Dangling and other-org ids are classed refused but the name read returns no row for them, and served holds only rows the read returned, so they stay bare (no name invented). The branch runs only when fieldDef.referenceNameVisible === true. Nothing is served outside expandRelatedRecords, so a plain read answers the stored id for every reader (ADR-0104 D1: the stored form of a reference is the id string; the expanded form is ReferenceIdValueSchema | z.record(...), which the served object satisfies). All RIGHT and each pinned with its control in the REST test.
  • The refusal probe. Asked as the caller: canReadObject(referenceObject, caller) false means every id refused; else an id-only find as the caller with __expandRead: true (the full-treatment marker the authz middleware grants no waiver to) and the ids it does not return are refused; a thrown PERMISSION_DENIED (isReadRefusal) means all refused; any other failure means none refused and a warn. RIGHT: fail toward today's answer, and the probe forwards none of the caller's nesting.
  • sys_user through a user field and a lookup to sys_user. Same path via referenceTargetOf; sys_user carries nameField: 'name'. RIGHT, no carve-out.
  • Public surface. ReferenceNameOnlyValue and serveReferenceNames are exported from an objectql module not re-exported from the package barrel (module imports the engine as a type only); isReadRefusal becomes an export of a sibling module. Spec publishes a changed .describe() and a liveness row flip. No authorable key, value or type changes.

② Semver level

The changeset .changeset/29460-reference-name-visible-served.md declares @objectstack/objectql: patch and @objectstack/spec: patch; both packages move (objectql code, spec describe text and liveness). @objectstack/rest gains only a test and takes none. The declaration lines in the changeset and the PR body are identical and each key appears on exactly one line, line-initial, in each carrier (PR body: one line-initial, one occurrence anywhere; changeset: lines 8 and 9):

Clause-②: yes (widening)
Metadata-protocol: none

Both as the claim declared. none is right: the key, its accepted values and its declared meaning are unchanged from PR #29379; the runtime catches up with the declaration. none takes patch, so the level matches. Check Changeset on the head is success.

③ Boundary flags

  • Forced test edit packages/spec/src/data/field-reference-name-visible.test.ts: section 3 flips from pinning the [PLANNED marker to pinning its absence plus the served shape string, and keeps the no-tracker-number pin; the bounds pins are untouched. The designed flip of a declared-ahead key, matching the card's "Done when". CONFIRMED.
  • Liveness row packages/spec/liveness/field.json: referenceNameVisible → live, evidence cites the module, the REST test and the parse door; state counts 104/6 → 105/5. Spec property liveness is success on the head. CONFIRMED.
  • Dev deviations: module-split host pattern and the isReadRefusal export (one predicate for both refusal-serving sub-reads) are the engine's standing pattern; the readRefused: true marker is judged right in ①; PR opened draft through the relay with no reviewers; the main-merge commit message carries the model-free trailer pair. All CONFIRMED.
  • Dogfood readings: the dev's os-verify-lock VERDICT exit 0 (397 files, 3277 tests), 109/109 dispatch-gates and the nine ablations are the dev's readings on 7e8a7b30; nothing was re-run here. The gate verdicts are the head's check-runs: success on Governed Surface Queue Guard, Lint & Repo Gates (all Lint lanes), Check Changeset, Spec property liveness, Dogfood Regression Gate, Live E2E UI, Build Docs, Build UI Deps, Type Check · debt ledger, Type Check · source gates, PR-body guards, Check PR Size, Check Documentation Links; still in_progress at this reading: Build Core, Test Core 1/8 to 8/8, Type Check · consumer gates, Type Check · workspace, Type Check UI, Build & E2E UI, Test UI 1/16 to 16/16. The required set is therefore not yet green; this record's PASS is on the contract, and landing still waits for those conclusions.
  • Open question 1 (name column kept by a field-level rule): A CONFIRMED. The ruling lifts the record-level refusal; nothing in it lifts a field grant. B would serve under the elevation a column the caller is FLS-denied, hand out a value the security layer would have masked (masks are computed there, not in the engine), or compute a reference hop under isSystem (the branch pin shows hidden-secret would ride into the title). That widens a security boundary, which triage sends back to the maintainer; it is not this card's. The describe states A.
  • Open question 2 (plain reads not served): A CONFIRMED, and ESCALATED to the maintainer as the one place the card and the tree disagree. The ruling's letter ("what the engine returns ... name and id; expand returns the same") and the card's "Done when" ("the read and $expand branches") read as both branches. ADR-0104 D1 fixes the unexpanded form of a reference at the id string for every reader; serving an object there reverses an accepted ADR, which Prime Directive [WIP] Add Chinese version of the documentation #13 allows only by a new ADR, never by a card. The dev chose the ADR and said so in the describe, the module header and the report. The describe's sentence is true of the tree. The maintainer either accepts the $expand-only delivery (the ui expands the field) or opens the ADR-0104 amendment; neither changes this record.
  • Open question 3 (a named Zod schema for the value): A CONFIRMED as a spec-lane follow-up. Today the expanded form of a lookup is ReferenceIdValueSchema | z.record(z.string(), z.unknown()), which the served object satisfies, so nothing is refused or warned meanwhile; the ui card restates the shape from the PR body's table until the spec names it.
  • PR body open question 3 (a refused target is named even when the caller's nested filter would exclude it): right by design; forwarding the filter is ablation 1, and a readable target the filter excludes keeps the bare id (control pinned). CONFIRMED.

④ Design judgment

packages/spec/src/data/field.zod.ts, the describe on referenceNameVisible, against the cited code, ruling 6574398310 letter B, ADR-0079 D4 to D6, ADR-0104 D1 and its first consumer (the ui shape table for #29462). Each sentence of the describe was read against the tree:

  • "Served on $expand: a target the reader is refused (the object's read gate or its row scope) answers { id, name, readRefused: true }, per element on a multi-value field" — holds (callerRefusedIds: object admission, PERMISSION_DENIED, or an id the caller's id-only read does not return; requiredPermissions is inside the gate). "an unexpanded read answers the stored id, as for every reader" — holds (no call site outside expandRelatedRecords). "Nothing else of the target ... displayField does not widen it" — holds (①). "A field-level rule on the name field still holds ... the Record # floor with the id" — holds (servableNameField, resolver floor Record # plus id). "A principal who can read the target is served exactly what it is served without the key" — holds (readable targets never enter bare). "Declared on the referencing field only ... Refused on any other type" — unchanged, the superRefine still refuses the key off lookup and user. No tracker number in the runtime string; the test pins it.
  • Attempts to break it: a key forged onto master_detail or tree is refused at parse, and the engine gates by the key alone, so the parse door is the whole defence (registry schemas pass ObjectSchema parse; acceptable, and the liveness row names that door). A canReadObject outage falls open to the probe read, which still applies the full gate. An expansion that failed for a non-refusal reason leaves every id bare; the probe then classifies without the failing nesting and names only the refused ones. An elevated read that fails keeps today's bare ids and warns. None breaks the describe.

Findings, none must-fix:

  • should-fix (describe wording): the describe names the served name as "the target object's nameField value", while the code it cites resolves ADR-0079 D4 (nameField, then displayNameField, then the derived title) and the card asked for that order. After D7's designate-only provisioning the two coincide for registry-owned objects, but the describe is narrower than the resolver; say "ADR-0079 display name" so the describe and resolveRecordDisplayName cannot drift.
  • should-fix (describe completeness): the describe lists three floor cases; servableNameField serves the floor in two more (an explicit nameField on a column that is neither title-typed nor autonumber, a deliberate D4 departure in the safe direction; and no usable field answer from the security layer). Add them or close the list with "among other cases the engine cannot read without computing what the security layer owns".

Implemented-by: claude/project-thread-jdgmh0
Reviewed-by: session_01ADHkmQJdgarUjPLNgFBY67

VERDICT: PASS

… row-serving door pin

The new `engine-reference-name-visible.ts` asks `canReadObject` to tell which
ids the caller's own `$expand` left bare it is refused. It runs only inside
`expandRelatedRecords`, after the expansion of a read its door already
admitted through the exposure decision, and serves no row of its own, so it
is classified not-a-door, beside the file-field hydration precedent.

Claude-Session: https://claude.ai/code/session_01ADHkmRXbaXBLCJeJXfoh21
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

席位核对 · ACCEPT · PR #29576 · 2026-11-02T20:39Z

Head-sha: 36a4f3f758992e8d830376947ae4f1b734ff281d

维护者速读(席位终稿)

核对六项

  1. 形态:draft → main,Fixes #29460。12 个文件加 1 个测试分类,在认领申报面内。被迫测试改动有两处,都核过。field-reference-name-visible.test.ts 是 describe 标记的设计翻转。row-serving-door-exposure.pin.test.ts 的 CALLERS 新增一条 not-a-door,席位读了 diff:+9 行,只加分类条目,理由与文件引用水合那条同形(只在已过门的 $expand 读里判断哪些 id 被拒,不自己服务行)。
  2. 前提实测:schema 面契约复审 PASS(本 PR 上 ## Contract review,CONTRACT_REVIEW_TIER,head 7e8a7b30,独立会话)。复审逐句把 describe 对到代码,并尝试从五个方向击穿,均未成立。复审之后只多了上面那条测试分类(7e8a7b30..36a4f3f7 仅 packages/core/src/security/row-serving-door-exposure.pin.test.ts +9),不触及复审范围,席位按 diff 核过,不另起复审。
  3. 门禁:本 head 全部 check-run 已读,57 success、8 skipped、0 失败,含 Test Core 1–8/8、Dogfood Regression Gate、Spec property liveness、Check Changeset。上一 head 唯一的红是 Test Core 5/8 的仓库级钉子(新调用方未分类),属本 PR 自己的问题,已修复。dev 的读数:新 REST 套件 23/23(旧构建 13 红)、9 条消融全部按预测转红并复原、dispatch-gates 110/110、dogfood 397 通过 0 失败。dev 说明了 109/109 没抓到的原因:该钉子属于 core 的 test:repo 项目,不在按 diff 路径推出的门禁族里。
  4. 接受的偏差:多出的 readRefused: true 是带内拒读标记(仿 metadataRefused 先例),不是目标的数据,复审判在裁决范围内。isReadRefusal 从兄弟模块导出,共用一个判定。
  5. 开放问题裁定:(1) 名称列被字段级规则挡住时给底,选 A,复审确认。(2) 普通读不服务,依据 ADR-0104 D1,交 Decision: referenceNameVisible is served on $expand only; the #29171 ruling names the plain read too, which ADR-0104 D1 fixes at the stored id #29580 由维护者定,席荐 A。(3) 值的具名 Zod schema,连同复审的两条 describe should-fix,交 spec 卡 spec(data): the referenceNameVisible describe names nameField where the engine resolves the ADR-0079 display name, lists three of its five name floors, and the served { id, name, readRefused } value has no named schema #29579。
  6. 落地条款:Clause-②: yes (widening),Metadata-protocol: none,PR 正文与 changeset 都有。semver:@objectstack/objectql、@objectstack/spec 均 patch。check-governed-merges --pr 29576:NOT governed。落地授权,维护者原话:「按照 pm-dispatch 规则复核过的都授权」(2026-10-25T11:35Z)。

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

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server: serve the target's name and id for a referenceNameVisible lookup when the caller cannot read the target (#29171 ruling B)

1 participant