Skip to content

fix(components,plugin-detail): the declared nameField outranks titleFormat in every record-title reader (ADR-0079 order) - #10358

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-9436-page-header-title-order
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-9436-page-header-title-order

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #9436
Clause-②: yes

This PR executes ruling C1 on this card: the ADR-0079 declared name pointer outranks titleFormat in every record-title reader. The authority is class-1 ruling comment 5657441402, ratified in comment 5814246926. The scope is the re-scoped claim, comment 5820165439: one ruled order, three readers, one PR. LookupField is left out (see Acceptance notes).

The new public export (why Clause-②: yes)

@object-ui/core now exports declaredNameField(objectDef). The function already existed privately in record-title.ts, and its behaviour is unchanged. It returns the object's declared record-title pointer exactly as getRecordDisplayName reads it at steps 1+2: nameField, then the deprecated displayNameField and NAME_FIELD_KEY aliases. It returns undefined when none is declared, and it never derives. Never deriving is the difference from resolveNameField, and it is what lets a caller put the type-aware derivation on a lower rung than its own titleFormat rendering. All three readers value it the same way, recordDisplayValueAt(record, declaredNameField(objectDef)). None of them re-types the ?? chain. The claim marks this export Clause-②: yes, so a review-tier contract review is owed before enqueue.

Authority, as read

  • @objectstack/spec 17.4.0 (installed), titleFormat describe: "[DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence."
  • @object-ui/core getRecordDisplayName docblock, step 3: "objectDef.titleFormat (rendered template) — LEGACY, render-only, kept for back-compat only; an explicitly-declared field (1/2) now wins over it."

PageHeaderRenderer title ladder, before and after

The new rung is inserted directly above the template. Nothing else moves, and the template keeps the header's own interpolation (i18n option labels, separator cleanup).

# Before (main) After (this PR)
1 explicit schema.title explicit schema.title
2 titleFormat, via the header's interpolation declared pointer: nameField, then displayNameField, when it holds a value on the record. This is a new rung, filtered by the same floor test as rung 4.
3 unified resolver: nameField, displayNameField, core formatTitleTemplate, type-aware derivation titleFormat, via the header's interpolation (unchanged)
4 record-key rung, only for an object naming no title field unified resolver (unchanged)
5 ${objectLabel} ${id} placeholder record-key rung (unchanged)
6 ${objectLabel} ${id} placeholder (unchanged)

On an object without a titleFormat, rung 2 answers exactly what rung 3 answered before. A pointer that is blank on the record falls through to the template, just as getRecordDisplayName walks from a blank steps 1+2 to step 3.

The other two readers of the same order

Why all three move together was measured on the stop report (comment 5820114435). With only the header moved, the synthesized page (page:header + record:details) showed a duplicate row under an identical H1 for a composite template. For a single-field template it also hid a row the H1 no longer showed. DetailView rendered HT-2026-001 - Acme Corporation over nameField: 'contract_no'.

Upgrade effect: whose H1 moves

The H1 moves on any object that declares BOTH a nameField (or displayNameField) and a titleFormat whose rendering differs from that field's value.

  • objectui examples/** and apps/**: zero objects declare titleFormat at all. In-repo declarations are test fixtures only.
  • ObjectStack platform objects, measured once at objectstack 61609edf and not re-derived by any gate here. The instrument was git grep -l for a titleFormat key over non-test, non-md sources, then a per-file co-occurrence scan for nameField / displayNameField.
    • 42 platform objects declare both.
    • 26 have a single-field template equal to the pointer, so nothing visible changes.
    • 16 have an H1 that moves. 11 of them move to another field's value: sys_import_job, sys_job_queue, sys_job_run, sys_session, sys_setting, sys_migration_journal, sys_activity, sys_audit_log, sys_comment, sys_sharing_rule and sys_webhook.
    • The other 5 declare nameField: 'id', so their H1 becomes the raw record id: sys_approval_action, sys_approval_approver, sys_approval_request, sys_automation_run and sys_http_delivery.

This is a known consequence of the ruled order, and nothing in the renderer works around it. The fix belongs to those objects' metadata in ObjectStack: designate a formula field as nameField, as the titleFormat describe itself advises for a composite title. The seat is carding that in objectstack. The changesets for @object-ui/components and @object-ui/plugin-detail state the same effect. The @object-ui/core changeset names the export.

Pins

  • packages/components/src/__tests__/page-header-title.test.tsx: the pin "titleFormat still outranks nameField (legacy header behaviour)" is rewritten, not deleted, as "the declared nameField outranks titleFormat (ADR-0079 protocol order, ruling needed(components/core): which rung wins the record title — PageHeaderRenderer ranks titleFormat above the ADR-0079 declared pointer, getRecordDisplayName ranks it below (option C of objectui#8351) #9436)". The H1 must equal the pointer's value, and the template must render nowhere.
    • A second pin covers the displayNameField alias.
    • Four controls: a blank pointer falls through to the template; the template still outranks the type-aware derivation; the template keeps the header's option-label interpolation (a select value renders as In Progress, not in_progress, which also guards against consulting the whole resolver above the template); an explicit schema.title still wins.
  • record-details.titleFormatNoDedupe-8351.test.tsx: the two old-order cases are rewritten, not deleted. "HALF 1" now asserts the pointer's row DROPS. "hides the row the DECLARED POINTER names, not the one the template names" is the other.
  • record-details.headerDedupeAgreement-9436.test.tsx: the stop report's probe, committed. It renders the real page:header H1 beside the real record:details body, for a composite and a single-field template, plus a no-pointer control.
  • DetailView.declaredPointerOutranksTitleFormat-9436.test.tsx: two pins (nameField, displayNameField) and three controls: no pointer, a blank pointer, and the view-level primaryField still winning.
  • record-title.declaredNameField-9436.test.ts: the export's contract. It covers the alias order, never deriving (with a control showing that resolveNameField would derive), and agreement with getRecordDisplayName at steps 1+2.

Ablations (one-shot, run at 96e1b800, the commit holding implementation and pins)

Each ablation used node ../objectstack/scripts/ablation-replace.mjs. That tool requires the anchor to hit exactly once, verifies the mutation on disk (anchor count 1 to 0, blob hash changed), then restores and proves the restore (blob equals HEAD, git diff HEAD empty). Each run covered the same five files, 38 tests. Vitest aliases these packages to src, so no dist was involved.

Ablation Mutation Red (predicted = observed) Green
A1: header back to template-first declaredTitle || becomes '' || both header order pins, and both agreement cases (the H1 assertion) 34, including every control
A2: dedupe back to template-first the declared-pointer branch becomes if (false) both flipped #8351 cases, and both agreement cases (the body assertion) 34
A3: DetailView back to template-first its declared rung becomes if (false) both DetailView pins 36

Later commits added the changesets, removed one unused eslint directive from the agreement test (a comment line), and merged main. None of them touches the mutated source lines.

Verification at d02362e7 (final head)

  • turbo run build --filter='@object-ui/plugin-detail^...': 11/11 successful. Then type-check of @object-ui/core, @object-ui/components and @object-ui/plugin-detail: exit 0. All five test files were confirmed inside the tsconfig.test.json programs by --listFiles.
  • From the repo root: pnpm exec vitest run packages/core/ packages/plugin-detail/ gave 367 files passed and 1 skipped (a pre-existing timezone skipIf), 5460 tests passed. pnpm exec vitest run packages/components/ gave 289 passed and 1 skipped, 2809 tests passed.
  • pnpm check:control-bytes OK. pnpm check:new-line-citations: 0 new citations. node scripts/check-changeset-presence.mjs: 9 source files of 3 released packages, 3 changesets. check-changeset-no-major, -fixed, -overwrite: OK. pnpm check:pending-changeset-literals OK. pnpm check:test-path-roots OK.
  • pnpm check:changeset-claims: report-only. It flagged 10 pending changesets naming a touched file. Each paragraph was read, and none is falsified: they concern tab walkers, action ids, name-space resolveNameField, NAME_FIELD_KEY being read only inside record-title.ts (still true: the export lives there), and interpolate() rendering titleFormat (still true).
  • ESLint, narrowed to the 9 changed .ts/.tsx files: 0 errors (warnings only, from rule classes already present in these files).
    • The population is eslint's own config: ESLint.isPathIgnored is false for 9 of 9.
    • The file count is read from --format json: 9.
    • Invariance: eslint.config.js sets no parserOptions / projectService (no type-aware linting), and no rule under eslint-rules/ reads other files. So this diff cannot move a verdict on an untouched file.
  • NOT MEASURED: pnpm check:readme-exports. Its prerequisite is every package's dist: its only findings were missing dist entries and the vacuity floors those trip. This diff edits no README, and the core / components READMEs had zero findings. CI runs it on a full build.

Hunk fences

Acceptance notes

The session for this change is https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC.


Generated by Claude Code

…leFormat in every record-title reader

ADR-0079 D3 and the @objectstack/spec titleFormat describe both say an
explicit nameField takes precedence over the legacy render-only template, and
@object-ui/core's getRecordDisplayName ranks them that way. Three readers of
the record H1 ranked the template first. They now rank it second, which
executes ruling C1 on objectui#9436:

- core: export the existing declaredNameField, with no semantic change, so
  every reader spells the pointer once.
- PageHeaderRenderer: a declared-pointer rung sits directly above the
  titleFormat rung. The template keeps the header's own i18n option-label
  interpolation, and every other rung keeps its place.
- DetailView.resolveDisplayTitle: the same rung sits above its template step.
- record:details H1 dedupe: when the declared pointer holds a value, that
  field is the H1, so its row is hidden and the template is not consulted.
  The old dedupe mirrored the old header order.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
…itleFormat in every record-title reader

- page-header-title: the old-order pin is rewritten to the protocol order,
  not deleted. It adds a displayNameField pin and four controls: a blank
  pointer falls through to the template, the template still outranks the
  derivation, the template keeps the header's option-label interpolation, and
  an explicit schema.title still wins.
- record-details 8351: the two old-order cases flip and are rewritten. The
  template outcomes now run on an object with no declared pointer, and a
  scan-not-peek case is added.
- record-details.headerDedupeAgreement: the real H1 and the real body are
  rendered together for a composite and a single-field template.
- DetailView: the declared pointer (and its displayNameField alias) outranks
  the template, with three controls.
- core: declaredNameField's contract, including that it never derives.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
…er change

The core changeset (minor) names the new export. The components and
plugin-detail changesets (minor, with a behaviour-change banner) name the
upgrade effect, using the census reading taken at objectstack 61609edf.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
…needed

react-hooks/static-components does not fire in a plain helper function, so the
directive was reported as unused, which is an error.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 10 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/6771-retire-body-child-list-dialect.md

  • names renderers/layout/containers.tsx → packages/components/src/renderers/layout/containers.tsx — edited by this change

    Non-rendering readers keep their arm on the same rule, and none of them renders anything: while a renderer still reaches stored body content, a reader that must see the SAME content keeps its arm, or the renderer draws what the reader cannot find. Those are the two tab-subtree walkers in renderers/layout/containers.tsx, app-shell's pageSchemaIntrospect (CONTAINER_KEYS) and PageBlockInspector (STRUCTURAL_PROP_KEYS, the inspector half of a stored properties.body), and the CLI's OBJECTUI_STRUCTURAL_KEYS — a file-IDENTIFICATION marker, where keeping body is what lets an old file still be recognised as an ObjectUI node and therefore refused, instead of silently not judged.

.changeset/7182-declared-action-ids-one-rule.md

  • names containers.tsx → packages/components/src/renderers/layout/containers.tsx — edited by this change

    New on @object-ui/types, beside actionRendersAt: the pure resolveDeclaredActionIds(elements, registeredActions), with the DeclaredActionsResolution / DeclaredActionsRefusal result types (the shape classifier stays module-internal: called with no registry, the function already returns the registry-independent verdict a renderer needs before its lookup). Both renderers call it; the whole-array switch in record-quick-actions.tsx and the per-element normalisation in containers.tsx are gone. The rule is closed: a string is an id, a non-null non-array object is an inline definition, and any other element (null, a number, a nested array) is refused at its index too. An all-id array resolves by name in authored order, first registration winning on a duplicate name; ids that name nothing are reported back with their index for the caller to warn about once its lookup has settled.

.changeset/7287-resolve-title-field-shared-ladder.md

  • names record-title.ts → packages/core/src/utils/record-title.ts — edited by this change

    ADR-0079 collapsed ~6 divergent record-title resolvers onto one — @object-ui/core's record-title.ts, whose header tells the "Untitled everywhere" story that produced it. plugin-detail grew one back. resolveTitleField now calls core's resolveNameField and nothing else, so the detail page, the list column and the lookup chip cannot disagree about which field titles an object.

.changeset/7997-detail-view-related-retired.md

  • names renderers/record-details.tsx → packages/plugin-detail/src/renderers/record-details.tsx — edited by this change

    Why it retired. @objectstack/spec declares no DetailView schema at all — every DetailView occurrence in packages/spec/src is prose about this repo's own RecordDetailView.tsx — so this array mirrored no protocol schema and drifted freely: it declared columns as TableColumn[] while the renderer it fed also accepted bare field names, { field, label } and legacy { name, label } spellings. The axis that carried the ruling was measured zero pull: no application code authored the member, both internal producers of a detail-view node (RecordDetailDrawer, renderers/record-details.tsx) synthesize it without related, and the only in-tree authorings carrying real columns were two documents — both rewritten here.

.changeset/8155-app-root-residue-swept.md

  • names containers.tsx → packages/components/src/renderers/layout/containers.tsx — edited by this change

    Swept as a class, not as two coordinates. Every other place in this tree that stated app was a bound expression-scope root is corrected in the same change — the diagnostic copy and its byte-pin, the ambient-scope docblocks in @object-ui/react (SchemaRenderer, useExpression), @object-ui/core (ActionRunner.ParamDef.visible, RowPredicateOptions.scope), @object-ui/components (form.tsx, containers.tsx), @object-ui/plugin-detail, @object-ui/plugin-form (docblock and README), @object-ui/app-shell and the console app, plus fourteen test fixtures that transcribed the old bag with an app key. The fixtures in @object-ui/app-shell now call buildExpressionScope instead of transcribing it, so that pair cannot drift again.

.changeset/8400-kanban-name-field-skip-set.md

  • names record-title.ts → packages/core/src/utils/record-title.ts — edited by this change

    A kanban card printed its record title twice — once as the card heading, once as the first row of the card body. ObjectKanban resolves each heading through ADR-0079's getRecordDisplayName, then builds a skip set so the title field's raw value is not rendered again as a card field. That skip set read objectDef.NAME_FIELD_KEY, a key nothing produces: @objectstack/spec@17's object schema declares nameField (canonical) and displayNameField (its deprecated alias), and NAME_FIELD_KEY occurs nowhere in the framework tree — this repo reads it only as the last rung of the compatibility ladder inside record-title.ts, and never emits it.

  • names record-details.tsx → packages/plugin-detail/src/renderers/record-details.tsx — edited by this change

    Deliberately one rung, unlike the same dedupe in record-details.tsx, which also carries deriveTitleField: that ladder filters a synthesized field list, whereas this one filters an author-declared cardFields, where dropping a field the author asked for is a worse failure than a repeated title. A regression test pins both directions, including an object whose declared and derived pointers disagree.

.changeset/8649-detail-renderer-undeclared-keys.md

  • names record-details.tsx → packages/plugin-detail/src/renderers/record-details.tsx — edited by this change

    @object-ui/plugin-detail — the annotation-erasing destructure default is gone from three renderers. record-details.tsx, record-highlights.tsx and record-related-list.tsx each annotated schema correctly and then wrote schema = {} as any. A destructuring default's type joins the annotated property type at the binding, so any erased the annotation for every read site in the file — declared keys and undeclared ones alike read any. No published surface moves: the exported annotations were always correct.

.changeset/8871-page-node-refuses-breadcrumbs.md

  • names core/src/utils/record-title.ts → packages/core/src/utils/record-title.ts — edited by this change

    What was measured, on this branch's base 93127bd6f. Zero readers, with a point-access probe rather than a bare word: on that base \.breadcrumbs scores 0 tree-wide (exit 1) against \.breadcrumb\b's 12 files tree-wide (10 under packages/) as the lit control. At head the same two probes read 16 and 13 and \.breadcrumbs is exit 0 over 4 files — every hit one of this branch's own four files (this changeset, the refusal pin, layout.ts, zod/layout.zod.ts) quoting the probe string, and the pin's own exclusions put head back at exit 1. The base reading is the measurement; the head reading is this branch's echo of it. The bare word would have lied — it also names Sentry's own unrelated concept (app-shell/src/observability/sentry.ts) and appears in two comments listing UI surfaces (core/src/utils/record-title.ts, layout/src/NavigationRenderer.tsx), so a bare probe reports five readers that do not exist.

.changeset/9174-interpolate-fastpath-trim.md

  • names packages/components/src/renderers/layout/containers.tsx → packages/components/src/renderers/layout/containers.tsx — edited by this change

    page:header's title/subtitle (and the record-title titleFormat) all go through interpolate() in packages/components/src/renderers/layout/containers.tsx. When the template contained a {token} the function collapsed and trimmed whitespace before returning; when it contained no { at all it returned the raw string untouched. A whitespace-only authored title (e.g. ' ') has no token, so it came back unchanged — truthy — and PageHeaderRenderer's {explicitTitle && ANGLE-BRACKETS(h1)} gate drew a blank h1. Because literalTitleText (page.tsx), which decides whether PageRenderer delegates its own heading to the authored header, already trimmed and correctly read "no title", PageRenderer also drew its own implicit heading — two ANGLE-BRACKETS(h1) elements on one document, the broken outline objectui#3434 closed, arriving through a different door.

.changeset/record-picker-label-placeholder-i18n-5637.md

  • names renderers/layout/containers.tsx → packages/components/src/renderers/layout/containers.tsx — edited by this change

    KNOWN GAP, unchanged by this release: the sibling label read sites in renderers/layout/containers.tsx compose translateLabel(pickLocalized(…), language), and that second helper is not applied here — translateLabel and its KNOWN_LABEL_DICT are module-private to that file. Only the locale-map resolution lands in this change; a plain-English string label is still rendered verbatim in every language, exactly as before.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Angle-bracketed names in the quoted prose above are rewritten as ANGLE-BRACKETS(name): GitHub deletes tag-shaped fragments from a stored body, and a quote that silently loses the identifier it is about is worse than a visible repair.

Compared the checked-out tree with 8c10f4f71 (merge-base with origin/main): 9 file(s) changed outside .changeset/, read against 1331 pending declaration(s) that publish a body (1904 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3041.9 KB 3104.5 KB
Main entry chunk (gzip) 148.3 KB 350 KB
Entry file index-DcxcW2BO.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.68KB 6.20KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 541.72KB 129.49KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 222.47KB 61.83KB
fields (index.js) 253.70KB 64.09KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 34.99KB 11.45KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.15KB 11.05KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.08KB 3.95KB
plugin-calendar (index.js) 51.44KB 14.62KB
plugin-charts (index.js) 71.84KB 20.13KB
plugin-chatbot (index.js) 198.27KB 47.18KB
plugin-dashboard (index.js) 133.06KB 35.21KB
plugin-designer (index.js) 216.12KB 44.37KB
plugin-detail (index.js) 260.87KB 67.87KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 146.34KB 37.48KB
plugin-gantt (index.js) 168.31KB 41.45KB
plugin-grid (index.js) 215.46KB 58.88KB
plugin-kanban (index.js) 49.30KB 15.39KB
plugin-list (index.js) 114.13KB 28.14KB
plugin-map (index.js) 21.74KB 7.07KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.68KB 8.95KB
plugin-tree (index.js) 10.56KB 3.71KB
plugin-view (index.js) 85.79KB 21.35KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 110.46KB 36.33KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.78KB 2.56KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 20:08
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit ba0b61a Sep 24, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-9436-page-header-title-order branch September 24, 2026 20:20
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ltime): nine titleFormat-only system objects declare a title pointer instead of taking the raw id (objectstack-ai#20087)

Fixes objectstack-ai#20044

Clause-②: no

## What this changes

ADR-0079 resolves a record's title as `nameField`, then
`displayNameField`, then a derivation. An explicit `nameField` takes
precedence over the render-only `titleFormat`. The `titleFormat`
describe (`packages/spec/src/data/object.zod.ts`) states the migration:
「an explicit nameField now takes precedence … Migrate a single-field
title to nameField, a composite to a formula field designated as
nameField」.

Nine services-lane objects declared a `titleFormat` and no pointer. At
registration the registry's designate-only pass (`provisionPrimary(…, {
synthesize: false })`) derives the first title-eligible field, which on
all nine is `id`, and stamps it as `nameField`. A `/meta` read serves
that stamp as if the author had written it. objectstack-ai/objectui#9436
(landed as objectstack-ai/objectui#10358) makes the record page honour
the declared pointer, so each record page's H1 becomes the raw id once
the console pin moves past it. This is the same remedy PR objectstack-ai#20042 applied
to five objects (objectstack-ai#20015), repeated for these nine.

### Measured per object

Each declaration was registered in a real `ObjectQL` registry on
in-memory SQLite. One representative row was seeded and read back
through `findOne`, and its H1 was read under ADR-0079's order next to
the `titleFormat` rendering. The "main" columns were measured at
`b76aad5f6f`, with the declarations as they stand on `main`. The "this
branch" columns come from the committed pins.

| Object | `titleFormat` | main: registered `nameField` /
`displayNameField` | main: H1 | `titleFormat` rendering | this branch:
pointer | this branch: H1 (= `resolveRecordTitle`) |
|:---|:---|:---|:---|:---|:---|:---|
| `sys_approval_delegation` | `{delegator_id} → {delegate_id}` | `id` /
none | `RcqJgHufg-44Qdbj` (raw id) | `usr_alice → usr_bob` |
`display_title` | `usr_alice → usr_bob` |
| `sys_position_permission_set` | `{position_id} → {permission_set_id}`
| `id` / none | `2lvPKcxu8uDzPCzk` (raw id) | `pos_sales → ps_crm_edit`
| `display_title` | `pos_sales → ps_crm_edit` |
| `sys_user_permission_set` | `{user_id} → {permission_set_id}` | `id` /
none | `F06WPbxwwcDPtxYt` (raw id) | `usr_alice → ps_crm_edit` |
`display_title` | `usr_alice → ps_crm_edit` |
| `sys_user_position` | `{user_id} → {position}` | `id` / none |
`WeYJnXQkg-O3NKIh` (raw id) | `usr_alice → sales_manager` |
`display_title` | `usr_alice → sales_manager` |
| `sys_notification_delivery` | `{channel} → {recipient_id}` | `id` /
none | `MjrV1FGgJdUhhoj7` (raw id) | `email → usr_alice` |
`display_title` | `email → usr_alice` |
| `sys_notification_preference` | `{user_id} · {topic} · {channel}` |
`id` / none | `YuzjUl0Lsxsk4L9v` (raw id) | `usr_alice · billing.invoice
· email` | `display_title` | `usr_alice · billing.invoice · email` |
| `sys_notification_receipt` | `{state}` | `id` / none |
`3IWhzyEYVsd9JGeI` (raw id) | `read` | `state` | `read` |
| `sys_notification_subscription` | `{principal} · {topic}` | `id` /
none | `aNY2Diw2O2_qvHT8` (raw id) | `role:sales_manager ·
billing.invoice` | `display_title` | `role:sales_manager ·
billing.invoice` |
| `sys_presence` | `{user_id} ({status})` | `id` / none |
`zRKJBDnWzfMtp9Ps` (raw id) | `usr_alice (away)` | `display_title` |
`usr_alice (away)` (scratch run, see Deviations) |

### The fix

The eight composites each declare `display_title`, a formula field with
`returnType: 'text'` over the columns their `titleFormat` names.
`nameField` and the `displayNameField` mirror point at it, as in objectstack-ai#20042.

| Object | `display_title` expression |
|:---|:---|
| `sys_approval_delegation` | `record.delegator_id + ' → ' +
record.delegate_id` |
| `sys_position_permission_set` | `record.position_id + ' → ' +
record.permission_set_id` |
| `sys_user_permission_set` | `record.user_id + ' → ' +
record.permission_set_id` |
| `sys_user_position` | `record.user_id + ' → ' + record.position` |
| `sys_notification_delivery` | `record.channel + ' → ' +
record.recipient_id` |
| `sys_notification_preference` | `record.user_id + ' · ' + record.topic
+ ' · ' + record.channel` |
| `sys_notification_subscription` | `record.principal + ' · ' +
record.topic` |
| `sys_presence` | `record.user_id + ' (' + record.status + ')'` |

`sys_notification_receipt`'s title is the single column `{state}`, so
`nameField` and `displayNameField` name `state` directly. That is the
describe's migration for a single-field title. An explicit pointer is
honoured whatever the field's type (ADR-0079 D4, `resolveDisplayField`).
`select` is kept out of derivation only, which is why the pass skipped
`state` and stamped `id`.

- **No NULL part reaches a formula.** Every column the titles read is
`required: true`, so the formulas carry no null guard, like objectstack-ai#20042's
required-column formulas. objectstack-ai#20042's NULL-part legs covered nullable
columns, and none of these nine titles has one. The write path refuses
an omitted title column with `VALIDATION_FAILED`, naming the field with
code `required`. Where the column declares a default, the write fills it
instead: preference `topic` / `channel` become `'*'`, receipt `state`
becomes `'delivered'`, and presence `status` becomes `'online'`. The
engine pins cover both behaviours. A row written around the engine with
a NULL title column (raw SQL) makes the formula evaluate to `null`. This
was measured in a scratch run, where the `titleFormat` rendering of that
row would be `usr_alice → ` instead.
- **No stored column.** A formula is computed on read, and the synced
tables carry no `display_title` column. No search-companion column
appears either: a formula is never a companion source, and `select` is
not title text. The pin runs `provisionSearchCompanion` over the
registered body, the step a pinyin-enabled registry runs, and
`resolveSearchCompanionSources` answers `[]`. No migration runs.
- **`titleFormat` is unchanged** on all nine objects, for renderers that
still read it first.
- **`$search` scans the same fields.** A formula is never a search
target, and neither was `id`. On `sys_notification_receipt`, `state` (a
`select`) was already in the auto-default set and now leads it. The lead
changes the order only, never the members, and none of the nine declares
`searchableFields`.

## Security: the three permission-assignment tables

`sys_position_permission_set`, `sys_user_permission_set` and
`sys_user_position` bind permissions, so the new field was checked
against their existing read access:

- Each formula reads only its own row's columns: the foreign keys
(`position_id`, `permission_set_id`, `user_id`) and the `position` name.
It never reads a field of the record a key points at, so it never
traverses a lookup the reader may not see.
- None of those columns is `hidden`, guarded by `requiredPermissions` or
masked (`maskingRule`). The pin asserts all three per column, and each
was ablated on its own. They were already served to every reader of the
row, and they appear in `highlightFields` and `titleFormat`.
- No row scope, permission set, `apiMethods`, `managedBy` or
`userActions` entry changes. `display_title` is read-only. The shipped
permission sets grant these objects object-level access only
(`default-permission-sets.ts`) and carry no field entries.

## Tests

One new file per package. The three engine files boot the real
`ObjectQL` engine on in-memory SQLite with the real declarations:

- `plugin-approvals/src/sys-approval-delegation-display-title.test.ts`
(6 tests)
-
`plugin-security/src/objects/sys-security-assignment-display-title.test.ts`
(18 tests, 6 per object)
- `service-messaging/src/objects/notification-display-title.test.ts` (24
tests, 6 per object)
- `service-realtime/src/objects/sys-presence-display-title.test.ts` (2
tests; no engine, see Deviations)

Per object, the engine files assert:

- the body the registry holds after registration names the new pointer,
for both `nameField` and `displayNameField`;
- a seeded row's H1 is the literal `titleFormat` text, is not the row's
id, equals the `titleFormat` rendering of that row, and
`resolveRecordTitle` agrees;
- a row missing a title column is refused (`VALIDATION_FAILED`, field
named, code `required`), or is filled from the declared default and
titled by it;
- the formula reads exactly the `titleFormat` columns, one level deep,
each `required`, none `hidden` / permission-guarded / masked;
- no `display_title` column exists in the synced table, and no search
companion is provisioned.

Runs at `17db356e01`, the head of this PR:

| Suite | Result |
|:---|:---|
| `@objectstack/plugin-approvals`, full (`vitest run --maxWorkers=2`, at
`d040485b5b`) | 51 files, 790 tests passed |
| `@objectstack/plugin-security`, full (at `d040485b5b`) | 134 files,
2663 tests passed |
| `@objectstack/service-messaging`, full (at `d040485b5b`) | 46 files,
503 tests passed |
| `@objectstack/service-realtime`, full (at `d040485b5b`) | 5 files, 33
tests passed |
| the three rewritten engine files, at `17db356e01` | 6 + 18 + 24 passed
|
| `typecheck`, all four packages, at `17db356e01` | exit 0;
`check:test-typecheck` OK for plugin-approvals (ledger unchanged) and
plugin-security (0 errors) |

Only the three engine test files changed between `d040485b5b` and
`17db356e01`. `tsc --listFiles` finds every new test file inside a
typecheck program: plugin-approvals and plugin-security
`tsconfig.test.json`, service-messaging and service-realtime
`tsconfig.json`.

### Ablations (committed state `17db356e01`,
`scripts/ablation-replace.mjs`)

The object files are imported relatively from source, so no `dist/` sits
on the resolution path and no rebuild is involved. Every leg printed `ok
mutation landed` before its run and `ok restored: blob == HEAD` after
it. The tree equalled `HEAD` after each of the 33 legs.

| Leg | Mutation | Result |
|:---|:---|:---|
| ptr-id, 8 formula objects | both pointers → `'id'` (objectstack-ai#20015's shape) |
red on the pointer and H1 tests of that object, e.g. `expected
'22o5f2s01PYV_LAL' to be 'usr_alice → usr_bob'`, `expected
'Hc3c8fIEvdtL9EZZ' to be 'pos_sales → ps_crm_edit'`, `expected
'1-dAWCH9Y12B-1z4' to be 'email → usr_alice'`; presence: `expected 'id'
to be 'display_title'` from the designation pass |
| ptr-removed, receipt | both pointers deleted (`main`'s state) | red 3:
`expected 'id' to be 'state'`, `expected 'fKk2vvHoRePGTkDD' to be
'read'`, `expected 'nwVM_6ig0mkb28sJ' to be 'delivered'` |
| ptr-removed, delegation and presence | both pointers deleted | red 1
each, on the `displayNameField` mirror only (`expected undefined to be
'display_title'`); the H1 stays right, see Acceptance notes |
| req, 8 objects | one title column `required: true` → `false` | red on
the refusal test (`promise resolved … instead of rejecting`) and the
inputs test (`recipient_id: expected false to be true`); presence: the
inputs test |
| withheld, 6 legs | `hidden: true` on an input (delegation, delivery,
presence, `sys_position_permission_set`); `requiredPermissions` on
`sys_user_permission_set.user_id`; `maskingRule: 'name'` on
`sys_user_position.position` | red on the inputs test each time, e.g.
`user_id: expected [ 'view_assignment_subjects' ] to deeply equal []`,
`position: expected 'name' to be undefined` |
| col, 7 objects | `Field.formula(` → `Field.text(` | red 4 or 5,
including `to not include 'display_title'` and the companion pin |
| companion-receipt | `state: Field.select([…], {` → `state:
Field.text({` | red 1: `expected [ 'state' ] to deeply equal []` |

The first version of the companion pin read the synced table's columns
for `__search`. The engine the tests boot builds its registry with the
companion off (it follows `OS_SEARCH_PINYIN_ENABLED`), so that pin could
not fail, and the companion-receipt leg stayed green. It was replaced by
the `provisionSearchCompanion` form above, which goes red on that leg.

## Gates

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` from the real diff derived 62 families. All 62 were run at
`17db356e01`, and `--ran` (with an exit code recorded per family)
reconciles: "62 derived famil(ies) accounted for — 62 run, 0
NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of
them is 3)".

- `check:registry-log-declared` first went red on an intermediate commit
whose tests constructed a `SchemaRegistry`. That requires an
`OS_REGISTRY_LOG` declaration in three `vitest.config.ts` files outside
this change. The pin was rewritten not to construct one, and the gate is
green at `17db356e01`.
- `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (six
unrelated packages had no `dist/`). After building them it passed: "104
published require entry point(s) across 67 package(s) load".
- `GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs`:
exit 0.
- `check:i18n-coverage` (at `3fc935d944`, the bundle commit; nothing
after it touches a bundle or a declaration): OK, "13 config(s), 621
baselined untranslated string(s), none new".
- Narrowed eslint over the 41 changed `.ts` files (`--no-inline-config
--format json`): 41 files, 0 errors, 0 warnings, 0 ignored. The config
never enables type-aware linting (`eslint.config.mjs` around line 328:
no `parserOptions.project`), so a file's verdict depends on that file
alone. The repo-wide `pnpm lint` is CI's.

The derivation notes the tree is 7 commits behind `origin/main`. Two
gate inputs changed across that range:
`scripts/check-spec-docblock-symbol-anchors.mjs` and
`scripts/doc-authoring-prose-id.baseline.json`. None of those commits
touches a file in this diff.

## i18n

`node scripts/check-i18n-bundles.mjs --write --filter=…` regenerated the
four packages' bundles, and its output is committed unedited. The
English bundles gain `display_title`'s label and help. The zh-CN, ja-JP
and es-ES bundles carry the generator's English fill, and the
source-hash companions record those fills. A second `--write` is a
byte-for-byte fixed point. `check:i18n` and `check:i18n-stale-fill` are
green. objectstack-ai#20042 translated its new leaves by hand, but this dispatch said
the bundles are regenerated by tooling and never edited by hand. The
translated values can be hand-written in a later change, which AGENTS.md
allows.

## Deviations

- **`sys_presence`'s rendered-title pin is not committed.**
`@objectstack/service-realtime` declares neither `@objectstack/objectql`
nor `@objectstack/driver-sql`. An engine test there needs both as
devDependencies, which edits `package.json` and `pnpm-lock.yaml`,
outside the claimed file surface. The committed file pins the
designation pass (`provisionPrimary`, the step the registry runs) and
the formula's inputs. The rendered title was measured through the real
engine in a scratch run, not committed: `usr_alice (away)`, equal to the
`titleFormat` rendering.
- **The inherited commit (`3a1ea534a4`) was kept and amended by a
follow-up commit, not rewritten.** Its formulas were re-derived against
each `titleFormat` and all nine match. `sys_notification_receipt`'s
`titleFormat` is exactly `{state}`, so `state` is its one field. The
follow-up rewraps overlong comment lines and corrects "all required" for
two-column titles. It narrows the plugin-security note to what the
declared read path shows, and replaces a comment that promised the
presence test holds the rendered text.
- **`origin/main` was not merged.** It moved 7 commits, none touching a
file in this diff, so there is nothing to conflict.

## Acceptance notes

- Removing both pointers from a formula object does not bring the defect
back: `display_title` then wins derivation tier 2 (the `_title` affix).
Measured on `sys_approval_delegation` and `sys_presence`, where only the
`displayNameField` mirror went missing. The explicit pointer is kept
because the describe prescribes it and objectstack-ai#20042 declared it.
- A formula is a field of its own under field-level security. The masker
(`field-masker.ts` `maskResults`) deletes fields by name and knows no
formula inputs. A deployment that hides an input column through a
permission-set field entry therefore does not hide `display_title` with
it. That holds for every formula field, objectstack-ai#20042's five included. No
shipped declaration or permission set restricts these inputs. Noted, not
measured.
- Where an input is a lookup, the title carries the stored foreign key,
as the `titleFormat` substitution did (objectstack-ai#20042's `request_id` likewise).
- The `title-format-retired` lint warning stays on all nine objects,
because `titleFormat` stays (as in objectstack-ai#20042).

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant