Repository navigation
Commit 9969228
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
- .changeset
- content/docs
- data-modeling
- references
- api
- data
- system
- packages
- metadata-protocol/src
- spec
- authorable-surface
- liveness
- state-counts
- src
- data
- system
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
| 71 | + | |
71 | 72 | | |
72 | 73 | | |
73 | 74 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
960 | 960 | | |
961 | 961 | | |
962 | 962 | | |
| 963 | + | |
963 | 964 | | |
964 | 965 | | |
965 | 966 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
164 | 164 | | |
165 | 165 | | |
166 | 166 | | |
| 167 | + | |
167 | 168 | | |
168 | 169 | | |
169 | 170 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
341 | 341 | | |
342 | 342 | | |
343 | 343 | | |
| 344 | + | |
344 | 345 | | |
345 | 346 | | |
346 | 347 | | |
| |||
627 | 628 | | |
628 | 629 | | |
629 | 630 | | |
| 631 | + | |
630 | 632 | | |
631 | 633 | | |
632 | 634 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
298 | 298 | | |
299 | 299 | | |
300 | 300 | | |
301 | | - | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
302 | 304 | | |
303 | 305 | | |
304 | 306 | | |
305 | | - | |
| 307 | + | |
306 | 308 | | |
307 | 309 | | |
308 | 310 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
692 | 692 | | |
693 | 693 | | |
694 | 694 | | |
| 695 | + | |
695 | 696 | | |
696 | 697 | | |
697 | 698 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
34 | 41 | | |
35 | 42 | | |
36 | 43 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
15 | | - | |
| 15 | + | |
0 commit comments