Skip to content

feat(spec): ObjectSchema.imageField — an object declares its record picture, refused unless it names an image or avatar field - #21221

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21182-object-image-field
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21182-object-image-field

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 record chrome draws the record's picture from the object's imageField in the record page header — the renderer half of objectstack#21182 (ruling A on hotcrm#1199) 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

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/metadata-driven.mdx (via ObjectSchemaBase (symbol, a top-level const object))
  • content/docs/data-modeling/objects.mdx (via imageField (symbol, a field of const object ObjectSchemaBase, an authorable key of data/Object), imageField (literal, a string literal in a comment on a changed line; a string literal in refuseNonPictureImageField))
What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/authorable-surface/data.json, packages/spec/liveness/object.json, packages/spec/liveness/state-counts/object.md) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 137 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 ce4e205e2b6abd7a49f98a27cd8297a1e52f828b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d4099f859ca4ec1d1e3646ee333906d868b0a17a — the merge of head 06cb584a8a9c279870b50dfa7a8b2ba56e1fa3a6 into base ce4e205e2b6abd7a49f98a27cd8297a1e52f828b, 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 d4099f859ca4ec1d1e3646ee333906d868b0a17a && git checkout d4099f859ca4ec1d1e3646ee333906d868b0a17a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ce4e205e2b6abd7a49f98a27cd8297a1e52f828b 06cb584a8a9c279870b50dfa7a8b2ba56e1fa3a6 && git checkout -B drift-repro ce4e205e2b6abd7a49f98a27cd8297a1e52f828b && git merge --no-ff 06cb584a8a9c279870b50dfa7a8b2ba56e1fa3a6

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

⚠️ 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 ce4e205e2b6abd7a49f98a27cd8297a1e52f828b → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 06cb584a8a9c279870b50dfa7a8b2ba56e1fa3a6
Local-runs: none

Inputs: card #21182 (body and both comments, the claim and the os-dev-report), PR #21221 (body, 12-file list, net diff origin/main...06cb584a, merge base 862f12c0), the check-runs on the head, origin/main and the pinned objectui checkout at .objectui-sha 31971ff1 as read-only references. Nothing was built, run or re-run; every gate verdict below is the head's own check-run conclusion.

① Derived judgments

Accept-set changes the diff implies, each judged against origin/main:

  1. ObjectSchema widens by one optional key, imageField: string. Before: the strict shape refused the key as unrecognized_keys. After: accepted when it names an own key of fields whose parsed type is image or avatar; otherwise ONE custom issue at ['imageField']. Nothing that parsed before is refused, so the net movement is a pure widening. Right, and it is what card scope 1 and 2 ask for.
  2. The refusal rides ObjectSchema's existing superRefine, beside refuseForeignTreeReference. fields is a z.record (not an array), so own-property lookup is the correct resolution and the prototype-name case is refused rather than resolved (pinned). The judgment reaches all three authoring doors through the one schema: ObjectSchema.create() / defineStack, ObjectStackDefinitionSchema (os validate, issue at ['objects', 0, 'imageField']), and getMetadataTypeSchema('object') (the metadata save door). Each door is pinned in object-image-field.test.ts. Right. I confirmed the dev's premise myself: no door on origin/main refuses nameField for an undeclared name (display-name.ts#objectTitleCompleteness returns synthesized, validate-object-field-refs.ts leaves the scalar pointers out by its own header), so there was no nameField door to reuse.
  3. Prescription text. Names both accepted types and both factories (Field.image() / Field.avatar()), no tracker number in any runtime string; IMAGE_FIELD_TYPES is the single list read by the check and spelled into the message. IMAGE_FIELD_TYPES and refuseNonPictureImageField are module-private, so no new export. Right.
  4. ObjectExtensionSchema is unchanged: strict, no imageField, so the pointer is only ever the owning object's. Right, and no new refusal is owed there since an extension cannot carry the key.
  5. Published-surface prose. The .describe(), the TSDoc, the changeset and the objects.mdx row all say no renderer draws the key yet and that an empty field draws nothing (no initials or placeholder). That is the ruling's wording and keeps the claim as narrow as the enforcement (Prime Directive chore: version packages #10). Right.
  6. Generated and ratcheted artefacts, each checked for whether it should have moved: authorable-surface/data.json (+data/Object:imageField) moved; authorable-surface.base.json is the frozen anchor at baseRev and correctly did not; json-schema.manifest/** is a ratchet of def names, not properties, and correctly did not; packages/spec/json-schema/ is gitignored; dropped-refinements.baseline.json is keyed per schema and data/Object already carries its superRefine row, so no new row is owed; api-surface is symbol-level and no symbol was exported; content/docs/references/** (3 pages, 4 tables) regenerated; liveness/object.json +1 planned row and state-counts/object.md 51 → 52. The metadata-protocol /meta/types pin for object moves 43 → 44 on the field 74 → 75 precedent written in that same comment. The reference-sites derivation rejects *Field spellings by rule, so imageField is correctly not a reference site. All right.
  7. Liveness row object.imageField = planned, evidenceScope: cross-repo, verifiedAt 2026-10-01, evidence anchored to object.zod.ts#refuseNonPictureImageField (gate-checkable symbol) plus the objectui realm pointer at the pin. I re-read objectui at 31971ff1: containers.tsx has 0 imageField hits and 4 nameField hits; the only imageField hits in the repo are the gallery view's legacy option. Not authorWarn'd, on liveness README rule 1 (display metadata must NOT be marked) and the app.branding.logo row, whose wording this row follows. Spec property liveness is green on the head. Right.
  8. Form ↔ Zod reconciliation: one omit row for object.(root).imageField in the declared-not-enforced group, worded after externalSharingModel. Studio does not offer the key until it is enforced. Right; whether to offer it is left for the enforcement, as the row says.
  9. JSON Schema expressivity (note, not a defect of this diff). The "names an image/avatar field" rule is a custom check and cannot project into the published JSON Schema, the same class as the tree-reference rule; a validator reading the published file alone accepts a bad pointer. This is the existing data/Object dropped-refinement row, not a new one.

Public-surface changes beyond the schema: the exported types ServiceObject / ServiceObjectParsed gain one optional property (additive); no runtime package other than @objectstack/spec changes code (@objectstack/metadata-protocol moves a test pin only).

② Semver level

.changeset/21182-object-image-field.md: @objectstack/spec minor, body line Clause-②: yes (widening); PR body Clause-②: yes. A new optional key with a refusal that only fires when the new key is set is a widening of the accept set; yes takes at least minor, and minor is what is declared. Nothing is removed or renamed, so no migration text and no ADR-0087 disposition marker is owed. @objectstack/metadata-protocol publishes nothing from this diff (test only), so no second changeset is owed. The changeset's claim that the save door answers 422 INVALID_METADATA matches the envelope metadata-protocol/src/protocol.ts documents. Check Changeset is green on the head. Clause-②: yes (widening) — matches the diff.

③ Boundary flags

open_questions: none declared. Dev flags from the os-dev-report deviations, each answered:

  1. Door choice (Zone 2 ✨ Set up Copilot instructions #2 falsified in part). Accepted — measured as in ①.2; the ruling asks for refusal at authoring/publish and the one superRefine reaches all three doors.
  2. Two files outside the claimed surface (metadata-form-zod-reconciliation.test.ts, protocol.meta-types-degenerate-derivation.test.ts). Accepted — both are pins the declared key moves, both in the fixture-triage class the claim allows, neither widens scope.
  3. Planned row without authorWarn. Accepted — README rule 1 and the app.branding.logo precedent, see ①.7.
  4. objectui read at the pin 31971ff1, not the card's 8001068b9c. Accepted — the pin is the reference this repo is held to, and I re-measured the same finding there.
  5. Attribution: model-free trailer pair instead of the harness-suggested trailer. Accepted — AGENTS.md's Multi-agent discipline requires exactly that pair; all four branch commits carry it, and the trailer-less merge commit is the shape check-commit-card-trailers.mjs exempts by name.
  6. Plain git merge of origin/main 862f12c0, no os-regen-merge.sh. Accepted — I diffed the merge base e18fea6d to 862f12c0: main brought packages/lint/** and two .changeset/*.md, none of which .gitattributes routes merge=os-regen, and none of the branch's generated files were touched on main.

out_of_scope_findings, each answered:

  • nameField existence is refused at no door. Accepted as a note. The deferral is already written into validate-object-field-refs.ts's own header as a separate decision; not a defect of this diff and not this card's scope.
  • An ObjectExtension can override the type of the field imageField names. Accurate, and escalated to the dispatching seat. registry.ts#mergeObjectDefinitions folds fields as { ...base.fields, ...extension.fields } and the folded object is not re-parsed through the object door, so a second package can redefine the named field as a non-picture after the refusal has passed. The gap is generic to every object-level field pointer (nameField, stageField, highlightFields, index columns) and pre-dates this diff; no renderer reads imageField today, so there is no reach. Not a reason to fail this PR. Whether to file a card against the fold (re-judging the merged object through the object door) is the seat's call under Prime Directive chore: version packages #10; I am read-only on the tracker.
  • Studio does not offer imageField until enforced. Accepted — the omit row records it; the offer is a ruling for when objectui#11383 lands.

Note for the dispatching seat, prose face, not a contract face: content/docs/data-modeling/schema-design.mdx keeps a second object-property table (nameField, titleFormat, highlightFields) that gained no imageField row. The card asked for one sentence in the object docs and objects.mdx carries it, so this is not a gap in the delivery.

Check-runs on the head (read 2026-10-01T18:32Z, after waiting for the last to conclude): 35 check-runs, 33 success, 2 skipped (Console Pin Gate, which runs only on a .objectui-sha change, and the opt-in Packed-tarball smoke), 0 failed, 0 still running. Green on the head include Lint & Repo Gates (the generated-artefact, liveness, changeset, ADR-0087 and docs gate family), Spec property liveness, Check Changeset, Governed Surface Queue Guard, Test Core (1–6/6), Build Core, Build Docs, all four Type Check jobs, Temporal Conformance, the three Dogfood Regression Gate shards and Dogfood Verify CLI. The diff touches no governed surface.

Implemented-by: claude/issue-21182-object-image-field
Reviewed-by: session_01UtnxvdiN376GF3sgXwAw4d

VERDICT: PASS

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

Projects

None yet

2 participants