Skip to content

Commit 87956dc

Browse files
spec(data): the name-only reference value a refused reader is served has a named schema, and referenceNameVisible says which name it carries (#29623)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8PrTYh9NTZ1gpojcZYPdiSm)_ Fixes #29579 Clause-②: yes (widening) Metadata-protocol: added ## 维护者速读 - 改了什么:`referenceNameVisible` 的说明改为写明下发的名字是 ADR-0079 显示名(`nameField`,否则 `displayNameField`,否则推导的标题字段),并列全五种下发 `Record #` 兜底的情形;下发值 `{ id, name, readRefused: true }` 有了具名 schema,并入展开后 lookup 值的联合;引擎的类型改为从它派生。 - 为什么改:这个值发给读不到目标记录的人,公开文字必须准确说明暴露了什么;旧说明只写 `nameField`、只列了三种兜底,下发形状在 spec 里没有名字。 - 风险与回滚:不改任何行为;联合里原有的开放记录成员本就接受这个值,所以没有值的判定变化。回滚即 revert 本 PR。 - 先看:`packages/spec/src/data/field-value.zod.ts`(新 schema 与联合);`packages/spec/src/data/field.zod.ts`(说明文字);`packages/objectql/src/engine-reference-name-visible.ts`(类型派生)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/data/field-value.test.ts`,预期全绿,含新用例「a reader refused the target of a referenceNameVisible field reads the NAMED name-only value」。 - 需要你做的:批准两个名字后再合并:新导出 `ReferenceNameOnlyValueSchema` / `ReferenceNameOnlyValue`,以及 `readRefused` 作为声明的键(triage 6587041711 要求合并前获维护者批准)。 ## What changes - **`packages/spec/src/data/field-value.zod.ts`.** New closed `ReferenceNameOnlyValueSchema` (`strictObject`): `id` (a `ReferenceIdValueSchema` record id), `name` (non-empty string), `readRefused: true`. Type `ReferenceNameOnlyValue` is its `z.input` type. `valueSchemaFor(def, 'expanded')` for the reference types is now `ReferenceIdValueSchema | ReferenceNameOnlyValueSchema | z.record(z.string(), z.unknown())`. It is a read form only; the stored form stays `ReferenceIdValueSchema`. - **`packages/spec/src/data/field.zod.ts`.** The `referenceNameVisible` JSDoc and `.describe()`: - item 1: the name is the ADR-0079 D4 display name `resolveRecordDisplayName` answers (`nameField`, else `displayNameField`, else the derived title field), no longer "the target object's `nameField` value"; - item 2: the `Record #` floor cases are now all five that objectql's `servableNameField` serves (field-level security, `maskingRule`, formula hop, an explicit pointer (or its `displayNameField` alias) at a column neither title-typed nor autonumber, no usable field-level answer), plus the ADR-0079 floor every reader gets (no resolvable or no non-empty name); - the describe names `ReferenceNameOnlyValueSchema`. - **`packages/objectql/src/engine-reference-name-visible.ts`.** The hand-written `ReferenceNameOnlyValue` interface is gone; the module imports the type from `@objectstack/spec/data` (the module already imported `@objectstack/spec/data` at run time, so the edge exists). No behaviour change. - **Regenerated.** `content/docs/references/**` (`field.mdx`, `object.mdx`, `system/migration.mdx` carry the field describe; `field-value.mdx` gains the section; `index.mdx` counts 1604 → 1605), `api-surface`, `export-origins`, `declaration-map`, `json-schema.manifest`, `authorable-surface/data.json`, and the strictness-ledger counts. - **Ledgers.** `dropped-refinements.baseline.json` gains `data/ReferenceNameOnlyValue` site `id` (the build named it: the `ReferenceIdValueSchema` refine does not project, the same as the `data/ReferenceIdValue` row); `type-alias-convention.pin.test.ts` 818 → 819 isomorphic pins; `strictness-ledger.test.ts` field-value sites 4 → 5. - **Changeset.** `@objectstack/spec` minor. `@objectstack/objectql` carries none: built `dist/` holds 0 hits for the new import comment against 1 for a sibling string (positive control); the type is erased and the module is not re-exported. ## Declarations, measured - **Clause-②: yes (widening).** The PR adds two exports (`ReferenceNameOnlyValueSchema`, `ReferenceNameOnlyValue`) and three authorable-surface rows, which is a widening of the published surface (precedent `FileRefusedValueSchema`, 02d9f69). No value's parse verdict moves: the expanded reference union already held `z.record(z.string(), z.unknown())`, which accepts `{ id, name, readRefused: true }`. - **Metadata-protocol: added.** The build writes three new rows into the authorable-surface shard `packages/spec/authorable-surface/data.json`: `data/ReferenceNameOnlyValue:id`, `:name`, `:readRefused`. That is the claim's measurement and its stated arm. The value is served, never written: the stored form refuses it (test `bad({ type: 'lookup' }, { id, name, readRefused: true })`). ## Tests All at head `0d181d41`, against a spec `dist/` rebuilt from this tree. - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/data/field-value.test.ts src/data/field-reference-name-visible.test.ts src/type-alias-convention.pin.test.ts scripts/strictness-ledger.test.ts` → `Test Files 4 passed (4)`, `Tests 69 passed (69)`. - `pnpm --filter @objectstack/spec typecheck`, `pnpm --filter @objectstack/objectql typecheck` → exit 0. - `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2` → `Test Files 541 passed (541)`, `Tests 10493 passed (10493)`. - `pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/data-reference-name-visible.test.ts` (the served-value end-to-end suite of PR #29576) → `Tests 23 passed (23)`. <details><summary>Reverse verification: the engine type follows the spec schema</summary> One-shot, not kept. `node scripts/ablation-replace.mjs` changed the spec schema's `name: z.string().min(1)` to `name: z.number()` (anchor 1 → 0, blob `e91212ba468a` → `e122c5983b7e`), then rebuilt spec and type-checked objectql: ```text spec-build-exit 0 objectql-typecheck-exit 2 src/engine-reference-name-visible.ts(147,26): error TS2322: Type 'string' is not assignable to type 'number'. ``` Line 147 is the `served.set(id, { id, name: resolveRecordDisplayName(...), readRefused: true })` call, so the engine reads the type from the rebuilt spec `.d.ts`. Restored (blob hash equals HEAD, `git diff HEAD` empty), spec rebuilt, objectql typecheck exit 0, `check:generated` all 14 current. </details> ## Acceptance notes - `packages/objectql/src/engine.ts` (the private `serveReferenceNames` docblock, ~L13860) still says the floor is served "when field-level security keeps that field from the caller", one case of five. It is a private method's comment that does not ship (0 hits in objectql `dist/index.d.ts`), and the module header of `engine-reference-name-visible.ts` already lists all of them. Not in this claim's file surface; left as is. ## Verification Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `0d181d41` (19 paths vs merge base `81cd9eb0e`). - 107 derived, 106 run with exit 0, 1 NOT MEASURED. `--ran` reconciliation: `✓ dispatch-gates --ran: 107 derived famil(ies) accounted for — 106 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).` - NOT MEASURED: `pnpm check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: it needs every package built). Left to CI. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 (shallow clone could not read its pinned positive control); after `git fetch origin 621a487…` it exits 0 (`48 cases pass`). - `pnpm --filter @objectstack/spec check:generated` → `✓ All 14 generated artifacts are up to date`. - `node scripts/check-platform-checklist.mjs --changed-from origin/main` (an exported interface left a module) → `OK — 2 anchored file(s) in the change set, 9 anchor(s) judged, 0 lost.` - `origin/main` at `5bd615eff` merges cleanly (`git merge-tree` exit 0); none of its commits since the base touch this diff's files. - Families CI runs (lint, `Test Core`, type-check lanes) are CI's run on this PR. ## Human Minimum - [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` is CI's run. - [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. ## Maintainer approval Warren approved the new public names `ReferenceNameOnlyValueSchema`, `ReferenceNameOnlyValue` and `readRefused` as named, with `Metadata-protocol: added` kept: 「全部批准」 on the decision card 「六个命名/措辞全部按推荐批准吗?」 in the project thread "Decide pending spec name approvals", 2026-11-03 00:08Z. Recorded on this PR in seat comment 6591825483. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbahxR9HAJnTs7UKhmn --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbahxR9HAJnTs7UKhmn)_ <!-- signed: domain:spec#3 · os-warren · session_01JQwxbahe4v7GBD94QeSnir --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c779611 commit 87956dc

21 files changed

Lines changed: 215 additions & 70 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
spec(data): the name-only reference value a refused reader is served has a named schema, and `referenceNameVisible` says which name it carries
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
A `lookup` or `user` field that declares `referenceNameVisible: true` answers `$expand` with `{ id, name, readRefused: true }` for a target the reader may not read. Until now that value had no name in the spec, and the field's description named the wrong source for `name` and only some of the cases where the name is withheld.
11+
12+
- **New export.** `ReferenceNameOnlyValueSchema` / `ReferenceNameOnlyValue` from `@objectstack/spec/data`: a closed object of the record id the referencing row holds, a non-empty `name`, and `readRefused: true`. It is a read form only. The stored form of a reference stays the bare id, so a record write never accepts it.
13+
- **Expanded lookup value.** `valueSchemaFor(def, 'expanded')` for the reference types lists the new schema beside the bare id and the open record object. The open record member already accepted this value, so no value parses differently than before; the new export and its authorable-surface rows are what the declaration records.
14+
- **Which name.** The `referenceNameVisible` description now says the name is the referenced object's display name as `resolveRecordDisplayName` resolves it: its `nameField`, else its `displayNameField`, else the derived title field. Before, it named `nameField` alone.
15+
- **When the name is withheld.** The description now lists every case where the `Record #` floor with the id is served instead of the name: the reader's field-level security keeps the name field from it; the field declares a `maskingRule`; it is a formula reading through another reference; an explicit `nameField` (or its `displayNameField` alias) points at a column that is neither title-typed nor autonumber; or no field-level answer can be had for the reader. A target with no resolvable or no non-empty name reads as the same floor.
16+
17+
**For consumers.** Code that types a lookup's expanded value can import `ReferenceNameOnlyValue` and check `readRefused === true` to tell a refused target from an expanded record. Show the name as text and link nothing.

‎content/docs/references/data/field-value.mdx‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Field Value schema — Data Protocol reference
33
navTitle: Field Value
4-
description: "Field runtime VALUE-shape contract. Reference for Address, AddressValue, CalendarDateValue, ClockTimeValue and 7 more: every property with its type and default."
4+
description: "Field runtime VALUE-shape contract. Reference for Address, AddressValue, CalendarDateValue, ClockTimeValue and 8 more: every property with its type and default."
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -41,8 +41,8 @@ this contract has (ADR-0104 performance budget).
4141
## TypeScript Usage
4242

4343
```typescript
44-
import { AddressSchema, AddressValueSchema, CalendarDateValueSchema, ClockTimeValueSchema, FileLikeValueSchema, FileReferenceIdValueSchema, FileRefusedValueSchema, FileValueSchema, InstantValueSchema, LocationValueSchema, ReferenceIdValueSchema } from '@objectstack/spec/data';
45-
import type { Address, AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileReferenceIdValue, FileRefusedValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
44+
import { AddressSchema, AddressValueSchema, CalendarDateValueSchema, ClockTimeValueSchema, FileLikeValueSchema, FileReferenceIdValueSchema, FileRefusedValueSchema, FileValueSchema, InstantValueSchema, LocationValueSchema, ReferenceIdValueSchema, ReferenceNameOnlyValueSchema } from '@objectstack/spec/data';
45+
import type { Address, AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileReferenceIdValue, FileRefusedValue, FileValue, InstantValue, LocationValue, ReferenceIdValue, ReferenceNameOnlyValue } from '@objectstack/spec/data';
4646

4747
// Validate data
4848
const result = AddressSchema.parse(data);
@@ -193,3 +193,16 @@ Type: `string`
193193

194194
---
195195

196+
## ReferenceNameOnlyValue
197+
198+
### Properties
199+
200+
| Property | Type | Required | Description |
201+
| :--- | :--- | :--- | :--- |
202+
| **id** | `string` | ✅ | The id of the referenced record, as the referencing record holds it |
203+
| **name** | `string` | ✅ | The referenced record's display name (its object's `nameField`, else `displayNameField`, else the derived title field), or the `Record #` floor with the id where a field-level rule on that field holds |
204+
| **readRefused** | `true` | ✅ | The reader was refused the referenced record (the object's read gate or its row scope): only its id and display name are served, and the name links nothing. Distinct from an expanded record and from a bare id. |
205+
206+
207+
---
208+

‎content/docs/references/data/field.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ const result = CurrencyConfigSchema.parse(data);
7878
| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (`sys_metadata`, `sys_metadata_history`): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and `os validate`, `os build` and the object save door refuse it. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. |
7979
| **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. |
8080
| **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. |
81-
| **referenceNameVisible** | `boolean` | optional | Declared name exposure (`lookup` and `user` fields only). TRUE declares that a principal who can read the record carrying this field may see the referenced record's display name (the target object's `nameField` value) and id even when that principal cannot read the referenced record. 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; an unexpanded read answers the stored id, as for every reader. Nothing else of the target: `$expand` on this field returns the same name and id only, the field's `displayField` does not widen it, and the name renders as text with no link to the target. A field-level rule on the name field still holds: when the reader may not read that field, it is masked, or it is a formula reading through another reference, the name is the `Record #` floor with the id. A principal who can read the target is served exactly what it is served without the key. Absent or false keeps the restricted-reference posture for a target the reader cannot read. Declared on the referencing field only; there is no target-object switch. Refused on any other type. |
81+
| **referenceNameVisible** | `boolean` | optional | Declared name exposure (`lookup` and `user` fields only). TRUE declares that a principal who can read the record carrying this field may see the referenced record's display name (the target object's `nameField`, else its `displayNameField`, else the derived title field) and id even when that principal cannot read the referenced record. Served on `$expand`: a target the reader is refused (the object's read gate or its row scope) answers `{ id, name, readRefused: true }` (`ReferenceNameOnlyValueSchema`), per element on a multi-value field; an unexpanded read answers the stored id, as for every reader. Nothing else of the target: `$expand` on this field returns the same name and id only, the field's `displayField` does not widen it, and the name renders as text with no link to the target. A field-level rule on the name field still holds, and the name field is read only where nothing the security layer owns must be computed: the name is the `Record #` floor with the id when the reader may not read that field, it declares a `maskingRule`, it is a formula reading through another reference, an explicit `nameField` (or its `displayNameField` alias) points at a column that is neither title-typed nor autonumber, or no field-level answer can be had for the reader. A target with no resolvable or no non-empty name reads as the same floor. A principal who can read the target is served exactly what it is served without the key. Absent or false keeps the restricted-reference posture for a target the reader cannot read. Declared on the referencing field only; there is no target-object switch. Refused on any other type. |
8282
| **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. |
8383
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted |
8484
| **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. |

0 commit comments

Comments
 (0)