Skip to content

fix(platform-objects): title ten identity objects by a declared pointer instead of the stamped id - #20095

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20059-identity-objects-title-formula
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20059-identity-objects-title-formula

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

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 fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id #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 fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id #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 $expands 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..338feda6dd) 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 338feda6dd 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


Generated by Claude Code

…ts whose titleFormat a pointer reproduces exactly

sys_account, sys_invitation and sys_verification gain display_title, a text
formula reproducing their all-text titleFormat, with nameField and the
displayNameField mirror pointing at it. sys_scim_subject has a single-field
titleFormat ('{user_id}'), so its pointer names user_id directly, the same
shape as sys_session.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
… a text formula instead of the stamped id

sys_business_unit_member, sys_member, sys_scim_group_member,
sys_scim_projection_grant, sys_team_member and sys_two_factor gain
display_title, a text formula over the columns their titleFormat names, and
point nameField and the displayNameField mirror at it. Their templates name a
lookup, which a formula reads as the stored id.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…title of the ten identity objects

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…ay_title and translate its nine leaves

Regenerated with `node scripts/check-i18n-bundles.mjs --write --filter=platform-objects`;
the zh-CN, ja-JP and es-ES leaves are translated by hand, and a second
`--write` returned the source-hash companions to their prior bytes.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/platform-objects, touching 23 documentable anchor(s).

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d4c897e0e700d96e29ff8a39e91c85f2a8a30909.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 3 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 d4c897e0e700d96e29ff8a39e91c85f2a8a30909 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 d4c897e0e700d96e29ff8a39e91c85f2a8a30909 → 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: 141fcb8c3ad21e7a618e110effc44088d8adcd0d

Scope: 16 files (+553/−0) on merge base 338feda6dd:

  • ten packages/platform-objects/src/identity/*.object.ts;
  • four *.objects.generated.ts translation bundles;
  • the new test identity-display-title.test.ts, and the changeset.

The ten object files at 338feda6dd are byte-identical to the PR's stated base 66960564d9. No governed surface is touched.

Read: card #20059, the PR body, the diff, all 34 check-runs, AGENTS.md, and the code at head and base.

Measured through a real ObjectQL registry and SqlDriver on better-sqlite3 :memory:, with the real declarations and the lookup targets (sys_user, sys_team, sys_business_unit, sys_scim_user, sys_scim_group) registered and seeded. Each reading ran once with the ten base declarations and once with the head declarations. The objectui repository is not reachable from the review, so every renderer-side claim is UNMEASURED.

① Derived judgments

  • Served pointer and title: RIGHT, 10/10.
    • At base every object served nameField: 'id' with no displayNameField, and the title resolved to the raw record id.
    • At head nine serve display_title for both pointers and sys_scim_subject serves user_id.
    • On a stored row the head title equals the titleFormat rendering on all ten and contains the object's own id on none. resolveRecordTitle agrees with that title at base and at head.
    • Projecting only [id, display_title] still yields the title on all ten.
    • sys_member.role is the one nullable source. A null role written through the engine takes the default and titles usr_Ab12 (member). A raw-SQL NULL titles usr_Ab12, where the titleFormat substitution would give usr_Ab12 ().
  • Storage and search: RIGHT.
    • columnInfo() shows no display_title column and no __search column on any synced table. resolveSearchCompanionSources is [] for all ten.
    • The $search allowed set is identical at base and head for all ten: a formula, a lookup and id never enter it.
    • No lookup, master_detail or reference in packages/ or examples/ targets any of the ten (0 hits; controls lookup('sys_user') 86, reference: 'sys_user' 142).
    • The server-side title consumers (plugin-audit, plugin-approvals, the runtime body-runner, the objectql search companion, lint) read the declared pointer, and resolveRecordTitle evaluates a formula pointer.
    • The better-auth consumer pins (better-auth-schema-parity, managed-extension-fields) pass 58/58 at head.
  • The six lookup-bearing objects: as disclosed. With a real $expand, the formula renders the stored ids (usr_Ab12 in bu_emea) where titleFormat on the expanded row renders names (Ada Lovelace in EMEA Sales), on 6/6. That is strictly better than the raw record id, which names neither party.
  • Sort and filter on the title pointer: made stricter, measured. At base orderBy and where on the pointer (id) were accepted. At head, orderBy display_title and where display_title are refused by assertOrderByIsMaterializable and its filter twin on the nine formula objects. sys_scim_subject still accepts both. No in-repo server consumer sorts or filters by the pointer. This is the same property fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id #20042's and fix(plugin-approvals, plugin-security, service-messaging, service-realtime): nine titleFormat-only system objects declare a title pointer instead of taking the raw id #20087's formula titles carry.
  • Translations: RIGHT. The four bundles have identical key structure (1583 leaves each, 0 missing and 0 extra per locale). display_title label and help are present on exactly the nine formula objects. The zh-CN, ja-JP and es-ES values are hand-written, not English fills. The four check:i18n* gates ran green in Lint & Repo Gates.
  • Tests: identity-display-title.test.ts passes 29/29 at head.
  • CI at this head: 34 runs, 31 success, 3 skipped, 0 failures, and all seven required contexts are success.

② Semver level

@objectstack/platform-objects: patch, Clause-②: no: RIGHT.

③ Boundary flags

Implemented-by: claude/issue-20059-identity-objects-title-formula
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 04:18
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit d624002 Sep 25, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20059-identity-objects-title-formula branch September 25, 2026 04:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants