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
Conversation
…ltime): declare a title pointer on nine objects that relied on titleFormat Eight composite titles become a display_title text formula designated as nameField; sys_notification_receipt's single-field title points nameField at state. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…altime): pin the nine title pointers through the registry Per object: the registered body names the new pointer, a seeded row's H1 is the titleFormat text and never the id, a row missing a title column never reaches the formula with a NULL part, the formula reads exactly the titleFormat columns on its own row (required, none withheld), and nothing adds a stored column. sys_presence has no engine in its package's dependency closure, so its file pins the designation pass and the formula's inputs only. Also rewraps the inherited object comments and narrows the plugin-security note to what the declared read path shows. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
Output of `node scripts/check-i18n-bundles.mjs --write --filter=<pkg>` for plugin-approvals, plugin-security, service-messaging and service-realtime, unedited: the English leaves come from the new formula fields' label and description, the translated locales carry the generator's default fill, and the source-hash companions record those fills. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…yin-enabled registry about the search companion The engine the tests boot builds its registry with the companion switched off (it follows OS_SEARCH_PINYIN_ENABLED), so "no __search column" could never fail there. The pin now registers each object in a registry built with `searchCompanion: true` and asserts no companion field is provisioned. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…ompanion step over the registered body instead of a second registry Constructing a SchemaRegistry in a test makes check:registry-log-declared require an OS_REGISTRY_LOG declaration in each package's vitest config, outside this change's surface. The registry provisions the companion by running `provisionSearchCompanion` over the body it has designated, so the pin now runs that pure step over the engine's registered body, and asks `resolveSearchCompanionSources` for the sources: none. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 9 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a9fbf69a090b2ed35cdbd30e56440c9fbfb11573 && git checkout a9fbf69a090b2ed35cdbd30e56440c9fbfb11573
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7ddf396109b9d423cf52c2e5b69b0e34f235e7f5 17db356e015ace978e64e642025164acdd6607b6 && git checkout -B drift-repro 7ddf396109b9d423cf52c2e5b69b0e34f235e7f5 && git merge --no-ff 17db356e015ace978e64e642025164acdd6607b6
node scripts/docs-audit/affected-docs.mjs --json 7ddf396109b9d423cf52c2e5b69b0e34f235e7f5
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36089293345 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Queue-build triage · run 36089293345 red: not this PR's diff ·
Generated by Claude Code |
…er instead of the stamped id (objectstack-ai#20095) Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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. - objectstack-ai#20044 (the nine services-lane objects) closed `completed` at 03:30Z through PR objectstack-ai#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>
Fixes #20044
Clause-②: no
What this changes
ADR-0079 resolves a record's title as
nameField, thendisplayNameField, then a derivation. An explicitnameFieldtakes precedence over the render-onlytitleFormat. ThetitleFormatdescribe (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
titleFormatand no pointer. At registration the registry's designate-only pass (provisionPrimary(…, { synthesize: false })) derives the first title-eligible field, which on all nine isid, and stamps it asnameField. A/metaread 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 #20042 applied to five objects (#20015), repeated for these nine.Measured per object
Each declaration was registered in a real
ObjectQLregistry on in-memory SQLite. One representative row was seeded and read back throughfindOne, and its H1 was read under ADR-0079's order next to thetitleFormatrendering. The "main" columns were measured atb76aad5f6f, with the declarations as they stand onmain. The "this branch" columns come from the committed pins.titleFormatnameField/displayNameFieldtitleFormatrenderingresolveRecordTitle)sys_approval_delegation{delegator_id} → {delegate_id}id/ noneRcqJgHufg-44Qdbj(raw id)usr_alice → usr_bobdisplay_titleusr_alice → usr_bobsys_position_permission_set{position_id} → {permission_set_id}id/ none2lvPKcxu8uDzPCzk(raw id)pos_sales → ps_crm_editdisplay_titlepos_sales → ps_crm_editsys_user_permission_set{user_id} → {permission_set_id}id/ noneF06WPbxwwcDPtxYt(raw id)usr_alice → ps_crm_editdisplay_titleusr_alice → ps_crm_editsys_user_position{user_id} → {position}id/ noneWeYJnXQkg-O3NKIh(raw id)usr_alice → sales_managerdisplay_titleusr_alice → sales_managersys_notification_delivery{channel} → {recipient_id}id/ noneMjrV1FGgJdUhhoj7(raw id)email → usr_alicedisplay_titleemail → usr_alicesys_notification_preference{user_id} · {topic} · {channel}id/ noneYuzjUl0Lsxsk4L9v(raw id)usr_alice · billing.invoice · emaildisplay_titleusr_alice · billing.invoice · emailsys_notification_receipt{state}id/ none3IWhzyEYVsd9JGeI(raw id)readstatereadsys_notification_subscription{principal} · {topic}id/ noneaNY2Diw2O2_qvHT8(raw id)role:sales_manager · billing.invoicedisplay_titlerole:sales_manager · billing.invoicesys_presence{user_id} ({status})id/ nonezRKJBDnWzfMtp9Ps(raw id)usr_alice (away)display_titleusr_alice (away)(scratch run, see Deviations)The fix
The eight composites each declare
display_title, a formula field withreturnType: 'text'over the columns theirtitleFormatnames.nameFieldand thedisplayNameFieldmirror point at it, as in #20042.display_titleexpressionsys_approval_delegationrecord.delegator_id + ' → ' + record.delegate_idsys_position_permission_setrecord.position_id + ' → ' + record.permission_set_idsys_user_permission_setrecord.user_id + ' → ' + record.permission_set_idsys_user_positionrecord.user_id + ' → ' + record.positionsys_notification_deliveryrecord.channel + ' → ' + record.recipient_idsys_notification_preferencerecord.user_id + ' · ' + record.topic + ' · ' + record.channelsys_notification_subscriptionrecord.principal + ' · ' + record.topicsys_presencerecord.user_id + ' (' + record.status + ')'sys_notification_receipt's title is the single column{state}, sonameFieldanddisplayNameFieldnamestatedirectly. 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).selectis kept out of derivation only, which is why the pass skippedstateand stampedid.required: true, so the formulas carry no null guard, like fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id #20042's required-column formulas. fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id #20042's NULL-part legs covered nullable columns, and none of these nine titles has one. The write path refuses an omitted title column withVALIDATION_FAILED, naming the field with coderequired. Where the column declares a default, the write fills it instead: preferencetopic/channelbecome'*', receiptstatebecomes'delivered', and presencestatusbecomes'online'. The engine pins cover both behaviours. A row written around the engine with a NULL title column (raw SQL) makes the formula evaluate tonull. This was measured in a scratch run, where thetitleFormatrendering of that row would beusr_alice →instead.display_titlecolumn. No search-companion column appears either: a formula is never a companion source, andselectis not title text. The pin runsprovisionSearchCompanionover the registered body, the step a pinyin-enabled registry runs, andresolveSearchCompanionSourcesanswers[]. No migration runs.titleFormatis unchanged on all nine objects, for renderers that still read it first.$searchscans the same fields. A formula is never a search target, and neither wasid. Onsys_notification_receipt,state(aselect) 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 declaressearchableFields.Security: the three permission-assignment tables
sys_position_permission_set,sys_user_permission_setandsys_user_positionbind permissions, so the new field was checked against their existing read access:position_id,permission_set_id,user_id) and thepositionname. It never reads a field of the record a key points at, so it never traverses a lookup the reader may not see.hidden, guarded byrequiredPermissionsor 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 inhighlightFieldsandtitleFormat.apiMethods,managedByoruserActionsentry changes.display_titleis 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
ObjectQLengine 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:
nameFieldanddisplayNameField;titleFormattext, is not the row's id, equals thetitleFormatrendering of that row, andresolveRecordTitleagrees;VALIDATION_FAILED, field named, coderequired), or is filled from the declared default and titled by it;titleFormatcolumns, one level deep, eachrequired, nonehidden/ permission-guarded / masked;display_titlecolumn exists in the synced table, and no search companion is provisioned.Runs at
17db356e01, the head of this PR:@objectstack/plugin-approvals, full (vitest run --maxWorkers=2, atd040485b5b)@objectstack/plugin-security, full (atd040485b5b)@objectstack/service-messaging, full (atd040485b5b)@objectstack/service-realtime, full (atd040485b5b)17db356e01typecheck, all four packages, at17db356e01check:test-typecheckOK for plugin-approvals (ledger unchanged) and plugin-security (0 errors)Only the three engine test files changed between
d040485b5band17db356e01.tsc --listFilesfinds every new test file inside a typecheck program: plugin-approvals and plugin-securitytsconfig.test.json, service-messaging and service-realtimetsconfig.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 printedok mutation landedbefore its run andok restored: blob == HEADafter it. The tree equalledHEADafter each of the 33 legs.'id'(#20015's shape)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 passmain's state)expected 'id' to be 'state',expected 'fKk2vvHoRePGTkDD' to be 'read',expected 'nwVM_6ig0mkb28sJ' to be 'delivered'displayNameFieldmirror only (expected undefined to be 'display_title'); the H1 stays right, see Acceptance notesrequired: true→falsepromise resolved … instead of rejecting) and the inputs test (recipient_id: expected false to be true); presence: the inputs testhidden: trueon an input (delegation, delivery, presence,sys_position_permission_set);requiredPermissionsonsys_user_permission_set.user_id;maskingRule: 'name'onsys_user_position.positionuser_id: expected [ 'view_assignment_subjects' ] to deeply equal [],position: expected 'name' to be undefinedField.formula(→Field.text(to not include 'display_title'and the companion pinstate: Field.select([…], {→state: Field.text({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 followsOS_SEARCH_PINYIN_ENABLED), so that pin could not fail, and the companion-receipt leg stayed green. It was replaced by theprovisionSearchCompanionform above, which goes red on that leg.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsfrom the real diff derived 62 families. All 62 were run at17db356e01, 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-declaredfirst went red on an intermediate commit whose tests constructed aSchemaRegistry. That requires anOS_REGISTRY_LOGdeclaration in threevitest.config.tsfiles outside this change. The pin was rewritten not to construct one, and the gate is green at17db356e01.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET (six unrelated packages had nodist/). 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(at3fc935d944, the bundle commit; nothing after it touches a bundle or a declaration): OK, "13 config(s), 621 baselined untranslated string(s), none new"..tsfiles (--no-inline-config --format json): 41 files, 0 errors, 0 warnings, 0 ignored. The config never enables type-aware linting (eslint.config.mjsaround line 328: noparserOptions.project), so a file's verdict depends on that file alone. The repo-widepnpm lintis 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.mjsandscripts/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 gaindisplay_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--writeis a byte-for-byte fixed point.check:i18nandcheck:i18n-stale-fillare green. #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-realtimedeclares neither@objectstack/objectqlnor@objectstack/driver-sql. An engine test there needs both as devDependencies, which editspackage.jsonandpnpm-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 thetitleFormatrendering.3a1ea534a4) was kept and amended by a follow-up commit, not rewritten. Its formulas were re-derived against eachtitleFormatand all nine match.sys_notification_receipt'stitleFormatis exactly{state}, sostateis 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/mainwas not merged. It moved 7 commits, none touching a file in this diff, so there is nothing to conflict.Acceptance notes
display_titlethen wins derivation tier 2 (the_titleaffix). Measured onsys_approval_delegationandsys_presence, where only thedisplayNameFieldmirror went missing. The explicit pointer is kept because the describe prescribes it and fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id #20042 declared it.field-masker.tsmaskResults) 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 hidedisplay_titlewith it. That holds for every formula field, fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id #20042's five included. No shipped declaration or permission set restricts these inputs. Noted, not measured.titleFormatsubstitution did (fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id #20042'srequest_idlikewise).title-format-retiredlint warning stays on all nine objects, becausetitleFormatstays (as in fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id #20042).Generated by Claude Code