Repository navigation
Commit d624002
fix(platform-objects): title ten identity objects by a declared pointer instead of the stamped id (#20095)
Fixes #20059
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`) says so and prescribes
the migration:
> [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.
Ten identity objects in `packages/platform-objects` declared a
`titleFormat` and no pointer. The registry's designate-only pass
(`provisionPrimary(…, { synthesize: false })` in `materializeBaseLayer`)
derived `id`, the first title-eligible field, and stamped `nameField:
'id'`. A `/meta` read serves that stamp. So a renderer honouring the
declared pointer (objectui#9436, landed there as objectui#10358) draws
the raw record id as the H1.
This PR follows the shape of PR #20042 (the five services-lane objects,
landed `7e6ca1787a`):
| Object | `titleFormat` | Migration | `nameField` / `displayNameField`
| Formula (`display_title`, `returnType: 'text'`) |
|:---|:---|:---|:---|:---|
| `sys_account` | `{provider_id} - {account_id}` | formula |
`display_title` | `record.provider_id + ' - ' + record.account_id` |
| `sys_business_unit_member` | `{user_id} in {business_unit_id}` |
formula | `display_title` | `record.user_id + ' in ' +
record.business_unit_id` |
| `sys_invitation` | `Invitation for {email}` | formula |
`display_title` | `'Invitation for ' + record.email` |
| `sys_member` | `{user_id} ({role})` | formula | `display_title` |
`record.role != null ? record.user_id + ' (' + record.role + ')' :
record.user_id` |
| `sys_scim_group_member` | `{scim_user_id} in {group_id}` | formula |
`display_title` | `record.scim_user_id + ' in ' + record.group_id` |
| `sys_scim_projection_grant` | `{role} → {user_id}` | formula |
`display_title` | `record.role + ' → ' + record.user_id` |
| `sys_scim_subject` | `{user_id}` | single field | `user_id` | none |
| `sys_team_member` | `{user_id} in {team_id}` | formula |
`display_title` | `record.user_id + ' in ' + record.team_id` |
| `sys_two_factor` | `Two-factor for {user_id}` | formula |
`display_title` | `'Two-factor for ' + record.user_id` |
| `sys_verification` | `Verification for {identifier}` | formula |
`display_title` | `'Verification for ' + record.identifier` |
- **Single field vs composite.** A template that is exactly one
`{field}` is single-field. It takes the pointer directly, as in
`sys_session` (`nameField: 'user_id'`) and the other `(single-field
titleFormat)` objects. A template with literal text is a composite, so
it becomes a formula.
- **Null guard.** Every source column is `required: true` except
`sys_member.role`. A `sys_member` row without a role is titled by its
user alone, so the formula does not fail to evaluate.
- **`titleFormat` is kept** on all ten objects for renderers that still
read it first. PR #20042 kept it too.
- **No stored column.** A formula is virtual. The measured synced SQLite
table has no `display_title` column on any of the nine objects. No
migration runs.
- **`$search` is unchanged.** The display field only orders the
auto-default set and never admits a field (`autoDefaultFields`,
`packages/spec/src/data/search-fields.ts`). `formula` is in
`SEARCH_VIRTUAL_TYPES`, and `lookup` and `id` are auto-excluded. The
`__search` companion refuses a formula and a lookup source
(`isCompanionSourceType`), and it refused the primary key before, so no
companion column appears either.
- **Expression tag import.** The objects use `import { F } from
'@objectstack/spec/shared'`, the subpath this package already imports
from. PR #20042 used the root entry.
The commits are split so the six lookup-bearing objects can be separated
if the seat wants that: `7def64154b` carries the four whose title a
pointer reproduces on every surface (`sys_account`, `sys_invitation`,
`sys_verification`, `sys_scim_subject`), and `3eb8abe463` carries the
six whose `titleFormat` names a lookup (see "Lookups" below).
## Measurement (real `ObjectQL` registry + `SqlDriver` on better-sqlite3
`:memory:`, real declarations)
One-off script, not committed. Each object was registered, synced,
written with a representative row, and read back with `findOne`. Base =
the ten files at `66960564d9`. Head = this branch.
| Object | served `nameField` base → head | H1 under ADR-0079 order,
base | H1, head |
|:---|:---|:---|:---|
| `sys_account` | `id` → `display_title` | raw id | `github - 5812039` |
| `sys_business_unit_member` | `id` → `display_title` | raw id |
`usr_Ab12 in bu_emea` |
| `sys_invitation` | `id` → `display_title` | raw id | `Invitation for
ada@example.com` |
| `sys_member` | `id` → `display_title` | raw id | `usr_Ab12 (admin)` |
| `sys_scim_group_member` | `id` → `display_title` | raw id | `scu_Qx90
in scg_ops` |
| `sys_scim_projection_grant` | `id` → `display_title` | raw id |
`billing_admin → usr_Ab12` |
| `sys_scim_subject` | `id` → `user_id` | raw id | `usr_Ab12` (a
renderer reduces the expanded user to its name) |
| `sys_team_member` | `id` → `display_title` | raw id | `usr_Ab12 in
team_core` |
| `sys_two_factor` | `id` → `display_title` | raw id | `Two-factor for
usr_Ab12` |
| `sys_verification` | `id` → `display_title` | raw id | `Verification
for ada@example.com` |
At base, `displayNameField` was absent and only `nameField: 'id'` was
stamped. `resolveRecordTitle` returned the raw id at base and returns
the head H1 above now. At head, the H1 equals the `titleFormat`
rendering of the same stored row for all ten.
### Lookups: where the formula and the `titleFormat` differ
The reference renderer is objectui's `formatTitleTemplate`
(`packages/core/src/utils/record-title.ts` at objectui `ff14e29`),
ported into the script. A formula is evaluated on the stored row before
`$expand` (`applyFormulaPlan` runs before `expandRelatedRecords` in
`find`/`findOne`). It cannot reach a related record: `validate.ts` in
`@objectstack/formula` states that nothing hydrates relationship
traversal at a formula value. The record page `$expand`s every lookup
(`buildExpandFields` in objectui's `RecordDetailView`). So on that page
the two differ for the six objects whose template names a lookup:
| Object | formula (= `titleFormat` on the stored row) | `titleFormat`
on an expanded row |
|:---|:---|:---|
| `sys_business_unit_member` | `usr_Ab12 in bu_emea` | `Ada Lovelace in
EMEA Sales` |
| `sys_member` | `usr_Ab12 (admin)` | `Ada Lovelace (admin)` |
| `sys_scim_group_member` | `scu_Qx90 in scg_ops` | `ada in Ops` |
| `sys_scim_projection_grant` | `billing_admin → usr_Ab12` |
`billing_admin → Ada Lovelace` |
| `sys_team_member` | `usr_Ab12 in team_core` | `Ada Lovelace in Core` |
| `sys_two_factor` | `Two-factor for usr_Ab12` | `Two-factor for Ada
Lovelace` |
For these six, the formula title carries the related record's stored id
where the `titleFormat` renderer showed its name. That is better than
the stamped raw id, and it is what PR #20042's `sys_approval_approver`
(`{approver} · {request_id}`) does. For `sys_scim_subject`, the lookup
pointer reproduces the `titleFormat` on both kinds of row. The three
all-text objects match on both kinds of row.
## Reach
- **No lookup targets the ten.** `git grep` for a `lookup('…')` /
`master_detail('…')` / `reference: '…'` naming any of the ten objects
under `packages/` and `examples/` returns 0 hits. Controls: 86
`lookup('sys_user')` hits and 141 `reference: 'sys_user'` hits in the
same trees. So no lookup chip, audit lookup-title resolution
(`resolveLookupTitles` in plugin-audit, which skipped these objects
while the title field was `id`) or approval display enrichment reads
their title today.
- **Server title consumers.** Nine non-test files call
`resolveRecordTitle`, `titleFieldOf` or `resolveDisplayField`. They all
read the declared pointer, and a formula pointer is the case
`resolveRecordTitle` evaluates.
- **objectui list defaults.** `leadWithNameField` leads a synthesized
default column list with the declared pointer. Before, that was the
stamped `id`. Now it is `display_title` (or `user_id`).
- **better-auth.** Nine of the ten are `managedBy: 'better-auth'`. The
full `@objectstack/plugin-auth` suite (adapter, schema parity, ADR-0105
D7 extension-field collision guard) is green with the new field.
- **Translations.** The only generated artefact these objects feed is
the object translation bundles. No generated docs page lists their
fields.
## Tests
New:
`packages/platform-objects/src/identity/identity-display-title.test.ts`,
29 cases. Per formula object it asserts:
- the pointer the registry's designate-only pass leaves on the served
body (`provisionPrimary(…, { synthesize: false })`, the same call
`materializeBaseLayer` makes) is `display_title` with the
`displayNameField` mirror, not `id`;
- `display_title` is a `formula` with `returnType: 'text'`, so it is
title-eligible and has no stored column;
- the formula, evaluated as the read path evaluates it
(`ExpressionEngine.evaluate(expression, { now, record })`, `null` when
not ok), renders the `titleFormat` text for a stored row and never
contains the row's id.
It also pins the `sys_member` null-role leg and the `sys_scim_subject`
pointer. platform-objects has no `@objectstack/objectql` dependency, so
the engine-level readings above come from the one-off script, not from a
new dependency.
| Run | Result |
|:---|:---|
| `@objectstack/platform-objects`, full suite, at `141fcb8c3a` | 55
files, 912 tests passed |
| `@objectstack/platform-objects` `typecheck` (3 programs incl.
`check:test-typecheck`), at `141fcb8c3a` | exit 0; `tsc --listFiles`
puts the new test in the `tsconfig.test.json` program |
| `@objectstack/plugin-auth`, full suite | 114 files, 2440 tests passed
|
| `@objectstack/plugin-security`, full suite | 133 files, 2648 tests
passed |
| `@objectstack/client`: the five files that import the identity objects
| 46 tests passed |
| `@objectstack/runtime` `action-execution-destructive.test.ts`,
`@objectstack/service-messaging` `recipient-locale-shape-parity.test.ts`
| 66 and 16 passed |
The consumer suites ran on `c48a6ee11d`, before the merge of `main`
`338feda6dd`. That merge brought in 17 commits
(`66960564d9..338feda`) that touched `packages/client` and
`packages/runtime`. CI at the merged head ran those suites green.
*(Corrected by the seat from contract review 5826600669.)*
### Red at base, and ablations (committed state, each restore proven
blob == HEAD, post-batch tree clean)
The test imports the object files relatively from source, so no `dist/`
sits on the resolution path.
| Leg | Mutation | Result |
|:---|:---|:---|
| base | all ten object files set to their `66960564d9` blobs | red
29/29, e.g. `expected { nameField: 'id', …(1) } to deeply equal {
nameField: 'display_title', …(1) }` |
| ptr (×9) | delete the `displayNameField` + `nameField` lines | red
1/29 each (the served-pointer case) |
| ptr-subject | delete `sys_scim_subject`'s pointer lines | red 1/29:
`expected { nameField: 'id', …(1) } to deeply equal { nameField:
'user_id', …(1) }` |
| type | `sys_member` `Field.formula(` → `Field.text(` | red 1/29:
`expected 'text' to be 'formula'` |
| guard | drop `sys_member`'s null guard | red 1/29: `expected null to
be 'usr_Cd34'` |
The ptr legs go red on the mirror alone. With the pointer deleted,
derivation still picks `display_title`, because `*_title` is a tier-2
name-ish affix in `resolveDisplayField`. The explicit pointer is what
the describe prescribes and what keeps the designation independent of
the field's name.
## Gates (at `141fcb8c3a`)
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 60 families from the real diff. All
60 were run, and `--ran` reconciles: "60 derived, 60 run, 0
NOT-MEASURED, 0 UNRUN".
- `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET: six
unrelated packages had no `dist/`. It was re-run green after building
them.
- Verdict lines include:
- `check:platform-object-tenancy-census`: "84 platform-namespace
objects, 58 in the machinery's reach, 26 outside it";
- `check:i18n`: all 9 packages in sync;
- `check:i18n-stale-fill`: 0 stale fills;
- `check:nul-bytes`: OK;
- `check:test-source-alias`: OK;
- `check:published-files`: OK.
- Also run: `check:i18n-coverage` OK ("621 baselined untranslated
string(s), none new"), and `GITHUB_TOKEN=… node
scripts/check-issue-citations.mjs --base 338feda` exit 0.
- Narrowed eslint (repo-wide `pnpm lint` is CI's). The population is the
15 changed `.ts` files, none ignored (no `ignored` notice in the json).
`--format json`: 15 files, 0 errors, 0 warnings. `eslint.config.mjs`
never enables type-aware linting (its own statement near line 327), so
this diff cannot move a verdict on an untouched file.
## i18n
Regenerated with `node scripts/check-i18n-bundles.mjs --write
--filter=platform-objects`. The nine `display_title` label and help
leaves are translated by hand in zh-CN, ja-JP and es-ES. A second
`--write` returned the three `source-hashes.generated.ts` companions to
their prior bytes, because none of the new leaves is a fill.
## Acceptance notes
- **The six lookup-bearing objects, and reading 2 of #20044.** The
triage note on this card says reading 1 "stays correct under either
answer" to reading 2 (the designation pass not stamping a derived
pointer on a `titleFormat` object). Measured, that holds for four of the
ten, not for six. If reading 2 is accepted, an object with no pointer
falls through to `titleFormat`, and on the expanded record page that
renders `Ada Lovelace in Core`. These six now declare a pointer, which
wins, so they would show `usr_Ab12 in team_core` instead. That is still
better than the raw id they show without reading 2. The six are in their
own commit (`3eb8abe463`) if the seat prefers to hold them for the
maintainer's answer. The same limit applies to PR #20042's
`sys_approval_approver`. A formula has no relationship traversal, so no
in-contract formula can title by a related record's name.
- `sys_member`: for a null role, objectui's renderer draws `usr_… ()`,
because `(` is not in its separator class. The formula draws the user
alone, matching PR #20042's null legs. An empty-string role renders
`usr_… ()` in both. In the engine measurement an inserted null role took
the field's default (`member`).
- `sys_verification`: better-auth writes `identifier` as
`reset-password:` plus the live reset token for a password reset
(`better-auth/dist/api/routes/password.mjs`). The `titleFormat` rendered
that already, and `identifier` is already served on every read of this
private object. `display_title` repeats that column and widens nothing.
The field's description ("Email address or phone number") is incomplete.
That is a pre-existing note, not changed here.
- `Clause-②: no` is carried from the claim. The change adds a read-only
virtual field to nine objects' read shapes and a pointer to all ten. It
widens no accepted input: a formula is not writable.
- #20044 (the nine services-lane objects) closed `completed` at 03:30Z
through PR #20087, before this PR opened. It is not touched here, and
reading 2 is not addressed here. *(Corrected by the seat from contract
review 5826600669.)*
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent a8bcce6 commit d624002
16 files changed
Lines changed: 553 additions & 0 deletions
File tree
- .changeset
- packages/platform-objects/src
- apps/translations
- identity
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
407 | 407 | | |
408 | 408 | | |
409 | 409 | | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
410 | 414 | | |
411 | 415 | | |
412 | 416 | | |
| |||
499 | 503 | | |
500 | 504 | | |
501 | 505 | | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
502 | 510 | | |
503 | 511 | | |
504 | 512 | | |
| |||
606 | 614 | | |
607 | 615 | | |
608 | 616 | | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
609 | 621 | | |
610 | 622 | | |
611 | 623 | | |
| |||
668 | 680 | | |
669 | 681 | | |
670 | 682 | | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
671 | 687 | | |
672 | 688 | | |
673 | 689 | | |
| |||
815 | 831 | | |
816 | 832 | | |
817 | 833 | | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
818 | 838 | | |
819 | 839 | | |
820 | 840 | | |
| |||
932 | 952 | | |
933 | 953 | | |
934 | 954 | | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
935 | 959 | | |
936 | 960 | | |
937 | 961 | | |
| |||
1049 | 1073 | | |
1050 | 1074 | | |
1051 | 1075 | | |
| 1076 | + | |
| 1077 | + | |
| 1078 | + | |
| 1079 | + | |
1052 | 1080 | | |
1053 | 1081 | | |
1054 | 1082 | | |
| |||
2091 | 2119 | | |
2092 | 2120 | | |
2093 | 2121 | | |
| 2122 | + | |
| 2123 | + | |
| 2124 | + | |
| 2125 | + | |
2094 | 2126 | | |
2095 | 2127 | | |
2096 | 2128 | | |
| |||
2162 | 2194 | | |
2163 | 2195 | | |
2164 | 2196 | | |
| 2197 | + | |
| 2198 | + | |
| 2199 | + | |
| 2200 | + | |
2165 | 2201 | | |
2166 | 2202 | | |
2167 | 2203 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
407 | 407 | | |
408 | 408 | | |
409 | 409 | | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
410 | 414 | | |
411 | 415 | | |
412 | 416 | | |
| |||
499 | 503 | | |
500 | 504 | | |
501 | 505 | | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
502 | 510 | | |
503 | 511 | | |
504 | 512 | | |
| |||
606 | 614 | | |
607 | 615 | | |
608 | 616 | | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
609 | 621 | | |
610 | 622 | | |
611 | 623 | | |
| |||
668 | 680 | | |
669 | 681 | | |
670 | 682 | | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
671 | 687 | | |
672 | 688 | | |
673 | 689 | | |
| |||
815 | 831 | | |
816 | 832 | | |
817 | 833 | | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
818 | 838 | | |
819 | 839 | | |
820 | 840 | | |
| |||
932 | 952 | | |
933 | 953 | | |
934 | 954 | | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
935 | 959 | | |
936 | 960 | | |
937 | 961 | | |
| |||
1049 | 1073 | | |
1050 | 1074 | | |
1051 | 1075 | | |
| 1076 | + | |
| 1077 | + | |
| 1078 | + | |
| 1079 | + | |
1052 | 1080 | | |
1053 | 1081 | | |
1054 | 1082 | | |
| |||
2091 | 2119 | | |
2092 | 2120 | | |
2093 | 2121 | | |
| 2122 | + | |
| 2123 | + | |
| 2124 | + | |
| 2125 | + | |
2094 | 2126 | | |
2095 | 2127 | | |
2096 | 2128 | | |
| |||
2162 | 2194 | | |
2163 | 2195 | | |
2164 | 2196 | | |
| 2197 | + | |
| 2198 | + | |
| 2199 | + | |
| 2200 | + | |
2165 | 2201 | | |
2166 | 2202 | | |
2167 | 2203 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
407 | 407 | | |
408 | 408 | | |
409 | 409 | | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
410 | 414 | | |
411 | 415 | | |
412 | 416 | | |
| |||
499 | 503 | | |
500 | 504 | | |
501 | 505 | | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
502 | 510 | | |
503 | 511 | | |
504 | 512 | | |
| |||
606 | 614 | | |
607 | 615 | | |
608 | 616 | | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
609 | 621 | | |
610 | 622 | | |
611 | 623 | | |
| |||
668 | 680 | | |
669 | 681 | | |
670 | 682 | | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
671 | 687 | | |
672 | 688 | | |
673 | 689 | | |
| |||
815 | 831 | | |
816 | 832 | | |
817 | 833 | | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
818 | 838 | | |
819 | 839 | | |
820 | 840 | | |
| |||
932 | 952 | | |
933 | 953 | | |
934 | 954 | | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
935 | 959 | | |
936 | 960 | | |
937 | 961 | | |
| |||
1049 | 1073 | | |
1050 | 1074 | | |
1051 | 1075 | | |
| 1076 | + | |
| 1077 | + | |
| 1078 | + | |
| 1079 | + | |
1052 | 1080 | | |
1053 | 1081 | | |
1054 | 1082 | | |
| |||
2091 | 2119 | | |
2092 | 2120 | | |
2093 | 2121 | | |
| 2122 | + | |
| 2123 | + | |
| 2124 | + | |
| 2125 | + | |
2094 | 2126 | | |
2095 | 2127 | | |
2096 | 2128 | | |
| |||
2162 | 2194 | | |
2163 | 2195 | | |
2164 | 2196 | | |
| 2197 | + | |
| 2198 | + | |
| 2199 | + | |
| 2200 | + | |
2165 | 2201 | | |
2166 | 2202 | | |
2167 | 2203 | | |
| |||
0 commit comments