Skip to content

Commit 9969228

Browse files
feat(spec): ObjectSchema.imageField — an object declares its record picture, refused unless it names an image or avatar field (#21221)
Fixes #21182 Clause-②: yes Seam: `spec:ObjectSchema.imageField` → `renderer:objectui record chrome (page:header, containers.tsx)` The platform half of ruling **A** on objectstack-ai/hotcrm#1199 (batch 261 item 3, 「其他四张同意」): an object declares which of its fields is the record's picture, and the record page header draws it. The liveness row is **`planned`**: no renderer reads the key yet. Its reader is objectstack-ai/objectui#11383 (the record chrome draws the record's picture from `imageField`), which is the seam's acceptance. This PR does not touch the renderer. ## What changed - **`ObjectSchema.imageField`** (`packages/spec/src/data/object.zod.ts`): optional `string`, declared after `nameField` and its two deprecated title companions. One object-level declaration, not a `page:header` prop. The describe and TSDoc say which reader draws it, say that reader is not live yet, and say an empty field shows no picture (no initials or placeholder). - **The refusal**, `refuseNonPictureImageField`, is called from `ObjectSchema`'s existing `superRefine` beside `refuseForeignTreeReference`. A name the object does not declare, or a declared field of any type except `image` / `avatar`, is ONE `custom` issue at `['imageField']`. Its prescription names both accepted types (`Field.image()` / `Field.avatar()`). Nothing on the consumer side tolerates a bad pointer. - **Liveness row** `object.imageField`: `planned`, `evidenceScope: cross-repo`, evidence anchored to the refusal and to the objectui record chrome at the pin. The note names the carrier objectstack-ai/objectui#11383. It is not `authorWarn`'d, on the `app.branding.logo` precedent and README rule 1: this is display metadata, and an authored value takes effect when the reader lands without being authored again. - **Form ↔ Zod reconciliation**: one `omit` row for `object.(root).imageField` in the "declared, not enforced yet — no offer until it is enforced" group, beside `externalSharingModel`. Without it the top-level zod-only leg goes red. This PR does not decide whether Studio should offer the key: that is a ruling for when the key is enforced. - **Docs**: one row in `content/docs/data-modeling/objects.mdx` → Display. It names `imageField`, its two accepted types, and says the refusal happens at validate/publish. It also says the record page header "is to draw" the picture once the renderer reads it, and that no renderer draws it yet. Regenerated: `authorable-surface/data.json`, `content/docs/references/**` (3 pages), `liveness/state-counts/object.md`. `api-surface` did not move: `check:api-surface` stayed green after rebuilding. - **Changeset** `.changeset/21182-object-image-field.md`: `@objectstack/spec` `minor`, `Clause-②: yes (widening)`. - **Fixture triage** in `@objectstack/metadata-protocol`: `protocol.meta-types-degenerate-derivation.test.ts` pins the number of top-level properties `/meta/types` serves for `object`. That count goes 43 → 44 because one key was declared, not because the derivation changed. This follows the `field` 74 → 75 (`picklist`) precedent already written in that comment. ## Doors measured: where `nameField` is judged, and why `imageField` is not judged there The dispatch asked for `imageField`'s check to go wherever `nameField`'s existence check lives. Measured at `origin/main` `e18fea6d`: **no door refuses `nameField` for naming an undeclared field.** `objectTitleCompleteness` classifies that case as `synthesized`, because the registry auto-provisions the primary. `validateRecordTitle` only warns, and only on `none`. `validate-object-field-refs.ts` explicitly leaves the scalar pointers (`nameField` / `stageField`) to a separate decision. So there was no `nameField` door to reuse. The existing door that matches is the one `refuseForeignTreeReference` uses: `ObjectSchema`'s own `superRefine`. Only the object holds the field map the pointer resolves against, and every authoring door parses through it. Each door is pinned in `object-image-field.test.ts`: | door | measured | |---|---| | `ObjectSchema.safeParse` / `.create()` | refused, `custom` at `['imageField']`; `create()` throws | | `defineStack` / `os validate` (`ObjectStackDefinitionSchema`) | refused, `custom` at `['objects', 0, 'imageField']` | | metadata save door (`saveMetaItem` → `getMetadataTypeSchema('object')`) | the registry resolves `object` to this schema; refused, `custom` at `['imageField']` | No new door was added and nothing in `@objectstack/lint` changed. ## Tests (tree `06cb584a`, HEAD after merging `origin/main` `862f12c0`) - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: **595 files, 17422 passed | 1 todo**, exit 0. Run at `56d27077`; the merge brought no `packages/spec` change. - `pnpm --filter @objectstack/spec run typecheck` (tsc + scripts + test-layer): exit 0. The new test file compiles under `tsconfig.test.json` and adds no debt. - `@objectstack/metadata-protocol`: full suite, **1 failed / 2972 passed** before the triage, the count pin above. After the triage the file reran at **18/18**. `typecheck` exit 0. - `@objectstack/lint`: **118 files, 5489 passed**, exit 0. The door is not in lint; I ran it because lint parses `ObjectSchema` fixtures. - `@objectstack/objectql`: not run. No fixture anywhere sets `imageField`: 0 hits repo-wide before this change, with `nameField` hitting as the control. The refusal only fires when the key is set, so nothing in objectql can move. - Pins: `src/data/object-image-field.test.ts`, 10 tests. Controls: `image` accepted, `avatar` accepted, key absent accepted. Refusals: undeclared, `text`, `file`, a prototype name (`toString`), `create()`, the save-door schema, and the stack door. Every refusal asserts issue `code` + `path`, plus the named subjects (the field name and both accepted types). ### Ablation (committed state, `scripts/ablation-replace.mjs`, wrap mode, restore proven) Spec's own tests import `src/`, so no `dist` was involved. - Removing the `refuseNonPictureImageField(...)` call from the `superRefine`: **7 failed | 3 passed**. All seven refusal pins went red and the three controls stayed green. Restore: blob `1d7f36b646ba` == HEAD, `git diff HEAD` empty. - Removing only the type half (`includes(type)` → `return`): **4 failed | 6 passed**. The red ones were `text`, `file`, `create()` (a `text` pointer) and save-door (a `text` pointer). The undeclared, prototype-name and stack-door pins stayed green, which is correct because they are existence refusals. Restore proven the same way. ### Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 110 commands at `06cb584a`. `--ran` reconciles **110 derived, 109 run, 1 NOT-MEASURED, 0 UNRUN**. Every run gate exited 0, including `check:generated`, `check:liveness`, `check:empty-state`, `check:authorable-surface`, `check:api-surface`, `check:docs`, `check:docs-spec-enumerations`, `check:doc-authoring`, `check:spec-docblock-symbol-anchors`, `check:changeset-no-major`, `check:adr-0087-registration` and `check:nul-bytes`. - `check:skill-examples` first answered exit 3 (PREREQUISITE NOT MET: `client-react` was unbuilt). After building `@objectstack/client-react...` it answered 259/259, exit 0. - `check:type-check-debt` was first killed by my own 280 s per-gate timeout. A full rerun gave exit 0: 26 raw errors, none above the record. - NOT MEASURED: `check:dual-build-cjs-loads`. Exit 3, PREREQUISITE NOT MET: it reads every package's `dist`, 32 are unbuilt in this worktree, and a whole-repo build does not fit the foreground cap. CI's `Lint & Repo Gates` runs it. ## Acceptance notes - **`nameField` existence is refused nowhere.** This is an observation, not a defect report: the auto-provision design makes `synthesized` deliberate. It is recorded here because it is why `imageField` did not inherit a "nameField door". - **An `ObjectExtension` can override the type of a field that the owning object's `imageField` names.** `ObjectExtensionSchema` cannot declare `imageField` (its strict shape refuses the key), but its `fields` may override an existing field's type. The merge happens after the object door, so the object parse cannot see such an override. No producer writes that shape today. Recorded here, not filed (no reach). - **objectui pin.** The record chrome was read at the `.objectui-sha` pin `31971ff1`, not at the card's `8001068b9c`. The finding is the same: the title comes from `nameField` and no image key is read. objectui's own `imageField` hits at the pin are the gallery view's legacy option (`galleryViewOptions`), a different key on a different schema. - **Studio offer.** The form leaves the key off until the key is enforced; see the omit row above. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c5a311 commit 9969228

12 files changed

Lines changed: 317 additions & 4 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): an object declares which of its fields is the record's picture — `imageField`, beside `nameField`
6+
7+
Clause-②: yes (widening)
8+
9+
`ObjectSchema` accepts one more optional key, `imageField`. It names the field
10+
whose value is the record's picture, the way `nameField` names the field that is
11+
the record's name: one object-level declaration, read by the record page header
12+
(the record chrome every record detail page shares) — not a per-page
13+
`page:header` prop.
14+
15+
```ts
16+
defineStack({
17+
objects: [{
18+
name: 'crm_account',
19+
nameField: 'name',
20+
imageField: 'logo',
21+
fields: {
22+
name: Field.text({ label: 'Name' }),
23+
logo: Field.image({ label: 'Logo' }),
24+
},
25+
}],
26+
});
27+
```
28+
29+
- **What it may name.** A field of the same object whose type is `image` or
30+
`avatar`. A name the object does not declare, or a field of any other type
31+
(a `text` URL column, a `file`), is refused at parse with an issue at
32+
`imageField` that names the two accepted types — so `defineStack`,
33+
`ObjectSchema.create()`, `os validate` and the metadata save door
34+
(`422 INVALID_METADATA`) all refuse it.
35+
- **An empty field.** The contract the reader is held to: a record whose
36+
picture field is empty shows no picture — no initials or placeholder in its
37+
place.
38+
- **Who draws it.** No renderer reads the key yet. The record chrome in
39+
`@object-ui/components` is the reader to come, and until it lands an authored
40+
`imageField` is accepted, stored and served but nothing draws it. It takes
41+
effect when that renderer ships, with no re-authoring. The liveness ledger
42+
records the key as `planned`.
43+
44+
Nothing that parsed before is refused: the key is new, and an object that does
45+
not set it is unchanged.

‎content/docs/data-modeling/objects.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const Account = ObjectSchema.create({
6868
| :--- | :--- | :--- | :--- |
6969
| `nameField` | `string` | optional | The stored field used as the record display name, e.g. `'name'` or `'title'` (ADR-0079). The deprecated alias `displayNameField` is still accepted. |
7070
| `titleFormat` | `string` | optional | Deprecated (ADR-0079 → `nameField`). Render-only title template (e.g. `'{{record.name}} - {{record.code}}'`); an explicit `nameField` takes precedence |
71+
| `imageField` | `string` | optional | The record's picture: names a field of this object whose type is `image` or `avatar` — any other name is refused when the object is validated or published. It is the one object-level declaration the record page header is to draw beside the title once the renderer reads it; no renderer draws it yet. |
7172
| `highlightFields` | `string[]` | optional | Most-important fields in priority order — default list columns, cards, previews, detail highlight strip (ADR-0085; formerly `compactLayout` — the old spelling was retired and is now rejected) |
7273
| `stageField` | `string \| false` | optional | Linear lifecycle field; `false` declares the status field non-linear and suppresses stage heuristics (ADR-0085) |
7374

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,7 @@ Metadata query with filtering, sorting, and pagination
960960
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
961961
| **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. |
962962
| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. |
963+
| **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) is to draw beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). Pending renderer: the record chrome does not draw it yet. |
963964
| **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. |
964965
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
965966
| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). |

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ const result = ApiMethod.parse(data);
164164
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
165165
| **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. |
166166
| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. |
167+
| **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) is to draw beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). Pending renderer: the record chrome does not draw it yet. |
167168
| **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. |
168169
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
169170
| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). |

‎content/docs/references/system/migration.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ Create a new object
341341
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
342342
| **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. |
343343
| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. |
344+
| **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) is to draw beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). Pending renderer: the record chrome does not draw it yet. |
344345
| **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. |
345346
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
346347
| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). |
@@ -627,6 +628,7 @@ Create a new object
627628
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
628629
| **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. |
629630
| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. |
631+
| **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) is to draw beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). Pending renderer: the record chrome does not draw it yet. |
630632
| **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. |
631633
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
632634
| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). |

‎packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,11 +298,13 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no
298298
* against the SERVED document. They prove two things at once: this suite
299299
* measures the same thing the card measured, and none of the thirteen was
300300
* disturbed by the fix. `field` moved 74 → 75 when it gained `picklist`
301-
* (the shared-option-list reference), a declared key, not a derivation change.
301+
* (the shared-option-list reference), a declared key, not a derivation change;
302+
* `object` moved 43 → 44 the same way when it gained `imageField` (the
303+
* record's picture, beside `nameField`).
302304
*/
303305
const CARD_PROPERTY_COUNTS: Record<string, number> = {
304306
agent: 26, app: 30, dashboard: 21, dataset: 16, field: 75, flow: 23,
305-
hook: 22, object: 43, page: 24, position: 12, report: 21, skill: 17, tool: 14,
307+
hook: 22, object: 44, page: 24, position: 12, report: 21, skill: 17, tool: 14,
306308
};
307309

308310
it.each(Object.entries(CARD_PROPERTY_COUNTS))(

‎packages/spec/authorable-surface/data.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,7 @@
692692
"data/Object:fileAccessDelegate",
693693
"data/Object:highlightFields",
694694
"data/Object:icon",
695+
"data/Object:imageField",
695696
"data/Object:indexes",
696697
"data/Object:isSystem",
697698
"data/Object:label",

‎packages/spec/liveness/object.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@
3131
"status": "live",
3232
"note": "ADR-0079 canonical record-title pointer; read by data/display-name.ts resolveDisplayField/resolveRecordDisplayName, objectql search displayField, and plugin-approvals."
3333
},
34+
"imageField": {
35+
"status": "planned",
36+
"verifiedAt": "2026-10-01",
37+
"evidenceScope": "cross-repo",
38+
"evidence": "packages/spec/src/data/object.zod.ts#refuseNonPictureImageField (the AUTHORING judgement only: a name the object does not declare, or a field of any type but `image` / `avatar`, is refused at parse, so at the defineStack, `os validate` and metadata-save doors alike; a value it ACCEPTS has no reader yet); objectui @31971ff1: packages/components/src/renderers/layout/containers.tsx (the record chrome resolves the header title from `nameField` and reads no image key — zero `imageField` hits there, control `nameField` 4 hits)",
39+
"note": "PLANNED, carrier objectui#11383 (the record chrome draws the record's picture from the object's `imageField` in the record page header), which is the seam's acceptance: flip to `live` citing that reader at the `.objectui-sha` pin that carries it. Declared by ruling A on objectstack-ai/hotcrm#1199 — one object-level declaration every record detail page reads, not a `page:header` prop, and no initials/placeholder avatar when the field is empty. ZERO consumers measured: in this repo `imageField` had 0 hits repo-wide before this key (control `nameField` hits), and objectui @31971ff1 has no object-level `imageField` read (its only `imageField` hits are the gallery view's legacy option, a different key on a different schema). Not `authorWarn`'d, on the `app.branding.logo` precedent and README rule 1: a picture pointer is display metadata, an author who sets it today loses nothing and is not misled about behaviour, and the value takes effect the moment the carrier lands with no re-authoring. The key's own describe says the renderer is pending."
40+
},
3441
"titleFormat": {
3542
"status": "live",
3643
"note": "objectui ({{record.field}} interpolation)."

‎packages/spec/liveness/state-counts/object.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
1212

1313
| Type | live | exp | elsewhere | dead | planned | classified |
1414
|---|---|---|---|---|---|---|
15-
| `object` | 50 | 0 | 0 | 0 | 1 | 51 |
15+
| `object` | 50 | 0 | 0 | 0 | 2 | 52 |

0 commit comments

Comments
 (0)