Skip to content

ADR-0079 title: 19 objects that declare titleFormat and NO pointer get nameField: id stamped by the registry designation pass, so a renderer honouring the declared pointer (objectui#9436) shows the raw record id as the title #20044

Description

@objectstack-fleet

Filing gate ① — a product defect with a named site and a measurement (class b: the served body presents a pointer the author never declared, contrary to the titleFormat / nameField contract).

The contract

The defect (measured by the #20015 dev at a02c8673d7; ⛔ not re-run by this seat)

The 19 objects, by package:

  • platform-objects (domain:engine): sys_account, sys_business_unit_member, sys_invitation, sys_member, sys_scim_group_member, sys_scim_projection_grant, sys_scim_subject, sys_team_member, sys_two_factor, sys_verification;
  • plugin-approvals: sys_approval_delegation;
  • plugin-security: sys_position_permission_set, sys_user_permission_set, sys_user_position;
  • service-messaging: sys_notification_delivery, sys_notification_preference, sys_notification_receipt, sys_notification_subscription;
  • service-realtime: sys_presence.

Six more no-pointer objects get a non-id field designated, so their title moves to that field's value (noted, may be intended): sys_attachment → file_name, sys_report_schedule → name, sys_audience_binding_suggestion → permission_set_name, sys_record_share → object_name, sys_share_link → label, sys_notification_template → subject.

Two readings of the remedy (for triage to route; ⛔ not decided here)

  1. Per object: each object declares a formula nameField reproducing its titleFormat, as the describe prescribes and as PR fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id #20042 does for five objects. This is per-package work, split by lane.
  2. The designation pass: it never stamps id as a primary title when the author declared a titleFormat and no pointer. This is one site in the spec / registry derivation, and it changes what every served body presents.

Reading 1 follows the describe's own migration instruction. Reading 2 prevents the class. Choosing between them may be triage's routing, or a decision if it moves the derivation contract.

Dedupe

One semantic issue search, open and closed, on 「objects declaring titleFormat without nameField get nameField id stamped by registry designation, record page title shows raw id after ADR-0079 order」: 8 hits.

Dedupe words: titleFormat no nameField designated id · provisionPrimary designate id H1 · registry stamps nameField id titleFormat · ADR-0079 derived pointer served as explicit · sys_notification_delivery record title raw id


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    分诊首次定级:priority:p2 · bug · domain:services · pm:queue —— 19 个只写了 titleFormat、没有名称指针的系统对象,被注册表的指定步骤盖上 nameField: id;objectui 按「声明的指针优先」渲染后,记录页标题会显示原始记录 ID。按修复落点拆开:本卡管服务组的 9 个,引擎组的 10 个(platform-objects)另开 #20059

    Path: packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts · packages/plugins/plugin-security/src/objects/(sys-position-permission-set、sys-user-permission-set、sys-user-position)· packages/services/service-messaging/src/objects/(notification-delivery、notification-preference、notification-receipt、notification-subscription)· packages/services/service-realtime/src/objects/sys-presence.object.ts

    Triage: lands in plugin-approvals / plugin-security / service-messaging / service-realtime ⇒ domain:services, bug, priority:p2, pm:queue; rationale: each of these nine objects declares a titleFormat and no pointer, the designate-only pass at the registry seam derives id as the first title-eligible field and stamps nameField: 'id', and a /meta read serves it as if declared (measured by the #20015 dev) — with objectui#9436 (ruling C1) now landed on objectui main as PR objectui#10358, the record-page H1 becomes the raw id once the console pin moves past it; the same regression #20015 fixed for five objects (PR #20042, landed), hence the same p2; the ten platform-objects objects land in domain:engine and are carried by #20059, filed by this seat in the same act; the class-level alternative (reading 2) changes what every served body presents and goes to the maintainer as a decision — neither card waits on it.

    分诊席 #6015,2026-09-25T00:26Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectstack origin/main 2274894cc4 上核对。

    本席核对

    为什么拆成两张

    修复落在 5 个包里,分属两个组:

    两张卡都是 p2、都已排队,互不等待。

    定级说明

    p2,与 #20015 同级:系统对象的记录页标题显示成原始 ID,管理员看不出是哪条记录。不涉及数据或权限。

    执行要点(按卡面的第一种修法,不需要新裁决)

    1. 照 titleFormat 描述自己写的迁移说明来做:单字段标题改成 nameField;复合标题做成文本公式字段,再指定为 nameField。做法和 PR fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id #20042 一样。
    2. 翻译文件用仓库工具重新生成,不要手改。
    3. 每个对象一个钉子:对象体的 nameField 指向新字段,渲染出的标题和原来 titleFormat 的输出一致,绝不是 ID。
    4. 最好在控制台钉版越过 objectui docs(ai): add the tool-record guide, routing readers to skills and materialised action tools first #10358 之前落地,否则这 9 个对象的标题会先退化成 ID。

    另一种修法交给维护者

    卡面的第二种修法是:对象写了 titleFormat 又没有指针时,指定步骤不盖推导出的指针。它能一次性防住这整类问题,客户自己的对象也包括在内;但它改变的是所有对象体对外呈现的内容,而且与上面那条「有意设计」相冲突,属于契约决定。本席已把它作为决策项交给维护者。本卡和 #20059 都不等它:第一种修法本来就是描述里规定的迁移,不管维护者怎么答都成立。


    Generated by Claude Code

  2. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01Evb5jFDZGKQE9KG4jbMfMF
    Branch: claude/issue-20044-services-title-pointers
    Worktree: objectstack-issue-20044
    Domain: domain:services
    Seat: domain:services#1
    File surface: the nine object files in triage's Path line (plugin-approvals sys-approval-delegation.object.ts; plugin-security src/objects/ sys-position-permission-set, sys-user-permission-set, sys-user-position; service-messaging src/objects/ notification-delivery, notification-preference, notification-receipt, notification-subscription; service-realtime sys-presence.object.ts); their translation bundles, only as regenerated by the repo's own tooling; new or extended test file(s) in those four packages; .changeset/20044-*.md. ⛔ No packages/spec, packages/objectql or packages/platform-objects (#20059, engine lane). ⛔ Not reading 2, the designation pass, which is with the maintainer. (Stop on breach; explain in the report.)
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
    Clause-②: no
    Thread-read: 5824561738
    Serial constraints cleared at 2026-09-25T01:26Z: none of the 20 open PRs touches plugin-approvals, plugin-security/src/objects/, service-messaging, service-realtime or platform-objects/src/identity/ (per-PR file lists read). The precedent PR #20042 (#20015, the same remedy for five objects) landed as 7e6ca1787a. The engine sibling #20059 is disjoint by package.

  3. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20044,
    "status": "done",
    "branch": "claude/issue-20044-services-title-pointers",
    "pr": "#20087",
    "session": "session_01Evb5jFDZGKQE9KG4jbMfMF (subagent of the domain:services seat; recovery dispatch)",
    "premise_still_valid": true,
    "summary": "Recovery of the restarted dev's work. Measured on main b76aad5, all nine objects register with nameField 'id' and a raw-id H1. The inherited commit 3a1ea53 was reviewed: all eight formulas match their titleFormat, and sys_notification_receipt's titleFormat is exactly {state}. It was kept and amended by follow-up commits: one test file per package, the generator-regenerated bundles (unedited, English fills in zh-CN/ja-JP/es-ES), a patch changeset for the four packages, and comment fixes. Draft PR #20087 is at 17db356. Tests, typecheck and 62/62 derived gates are green, and 33 ablation legs all went red and restored to blob == HEAD. One declared gap: sys_presence's rendered-title pin is not committed, because service-realtime has no engine in its dependency closure and adding devDeps is outside the claimed surface. It was measured in a scratch run instead: 'usr_alice (away)'. The assignee (os-sales) was not touched, and the newest Claim (5825149574) names this branch.",
    "measurement_table": "| Object | titleFormat | main: registered nameField / displayNameField | main: H1 | titleFormat rendering | this branch: pointer | this branch: H1 (= resolveRecordTitle) |\n|:---|:---|:---|:---|:---|:---|:---|\n| sys_approval_delegation | {delegator_id} → {delegate_id} | id / none | RcqJgHufg-44Qdbj (raw id) | usr_alice → usr_bob | display_title | usr_alice → usr_bob |\n| 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 |\n| 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 |\n| sys_user_position | {user_id} → {position} | id / none | WeYJnXQkg-O3NKIh (raw id) | usr_alice → sales_manager | display_title | usr_alice → sales_manager |\n| sys_notification_delivery | {channel} → {recipient_id} | id / none | MjrV1FGgJdUhhoj7 (raw id) | email → usr_alice | display_title | email → usr_alice |\n| sys_notification_preference | {user_id} · {topic} · {channel} | id / none | YuzjUl0Lsxsk4L9v (raw id) | usr_alice · billing.invoice · email | display_title | usr_alice · billing.invoice · email |\n| sys_notification_receipt | {state} | id / none | 3IWhzyEYVsd9JGeI (raw id) | read | state | read |\n| sys_notification_subscription | {principal} · {topic} | id / none | aNY2Diw2O2_qvHT8 (raw id) | role:sales_manager · billing.invoice | display_title | role:sales_manager · billing.invoice |\n| sys_presence | {user_id} ({status}) | id / none | zRKJBDnWzfMtp9Ps (raw id) | usr_alice (away) | display_title | usr_alice (away) (scratch run, see Deviations) |",
    "tests": "New files: plugin-approvals src/sys-approval-delegation-display-title.test.ts (6), plugin-security src/objects/sys-security-assignment-display-title.test.ts (18), service-messaging src/objects/notification-display-title.test.ts (24), service-realtime src/objects/sys-presence-display-title.test.ts (2). At 17db356 (the three engine files): 'Tests 6 passed (6)', 'Tests 18 passed (18)', 'Tests 24 passed (24)'. Full suites at d040485 (only these test files changed afterwards): plugin-approvals 'Test Files 51 passed (51) / Tests 790 passed (790)'; plugin-security 'Test Files 134 passed (134) / Tests 2663 passed (2663)'; service-messaging 'Test Files 46 passed (46) / Tests 503 passed (503)'; service-realtime 'Test Files 5 passed (5) / Tests 33 passed (33)'. Typecheck at 17db356, all four packages: exit 0; plugin-approvals 'check:test-typecheck: OK ... 8 file(s) / 324 error(s) / 27 pinned signature(s) held' (ledger unchanged); plugin-security 'check:test-typecheck: OK ... 0 file(s) / 0 error(s)'. tsc --listFiles counts each new test file once in a typecheck program. Ablation at 17db356 through scripts/ablation-replace.mjs, WRAP mode, no dist on the path (object files are imported relatively from source): 33 legs, each printing 'ok mutation landed' and 'ok restored: blob == HEAD', with tree == HEAD after every leg. ptr-id x8 (pointers -> 'id'): red, e.g. "expected '22o5f2s01PYV_LAL' to be 'usr_alice → usr_bob'" and "expected 'Hc3c8fIEvdtL9EZZ' to be 'pos_sales → ps_crm_edit'". ptr-removed-receipt: red 3 ("expected 'id' to be 'state'"). ptr-removed delegation/presence: red 1 each, on the displayNameField mirror only, because display_title wins derivation tier 2 without a pointer. req x8 (required: true -> false): red ("promise resolved ... instead of rejecting", "recipient_id: expected false to be true"). withheld x6 (hidden / requiredPermissions / maskingRule): red ("user_id: expected [ 'view_assignment_subjects' ] to deeply equal []", "position: expected 'name' to be undefined"). col x7 (Field.formula -> Field.text): red 4 or 5 each, including "to not include 'display_title'" and the companion pin. companion-receipt (select -> text): red 1, "expected [ 'state' ] to deeply equal []". A first companion pin, which read the synced columns for __search, stayed green on companion-receipt because the booted engine has the companion off. It was replaced before the final run. Narrowed eslint, 41 changed .ts files, --no-inline-config --format json: 41 files, 0 errors, 0 warnings, 0 ignored. The config has no parserOptions.project (eslint.config.mjs ~line 328), so no untouched file's verdict can move. The repo-wide pnpm lint is CI's.",
    "gates": [
    "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
    "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
    "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
    "node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
    "node scripts/check-ci-filter-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-corpus.mjs :: exit 0",
    "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
    "node scripts/check-empty-changeset.mjs --self-test :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
    "node scripts/check-registry-log-declared.mjs :: exit 0",
    "node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
    "node scripts/check-system-context-census.mjs :: exit 0",
    "node scripts/check-system-context-census.mjs --self-test :: exit 0",
    "node scripts/check-tenant-audit-census.mjs :: exit 0",
    "node scripts/check-tenant-audit-census.mjs --self-test :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
    "node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
    "node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
    "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
    "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
    "pnpm check:changeset-gate-self-tests :: exit 0",
    "pnpm check:cross-package-test-inputs :: exit 0",
    "pnpm check:doc-authoring :: exit 0",
    "pnpm check:driver-memory-census :: exit 0",
    "pnpm check:dts-closure :: exit 0",
    "pnpm check:dual-build-cjs-loads :: exit 0",
    "pnpm check:engine-double-contract :: exit 0",
    "pnpm check:gitlink-declared :: exit 0",
    "pnpm check:i18n :: exit 0",
    "pnpm check:i18n-stale-fill :: exit 0",
    "pnpm check:issue-citations :: exit 0",
    "pnpm check:lean-entry-closure :: exit 0",
    "pnpm check:logger-receiver-detach :: exit 0",
    "pnpm check:nul-bytes :: exit 0",
    "pnpm check:objectql-double-limit :: exit 0",
    "pnpm check:objectui-changeset :: exit 0",
    "pnpm check:org-identifier :: exit 0",
    "pnpm check:page-declaration-shape :: exit 0",
    "pnpm check:pm-changeset-deadline-census :: exit 0",
    "pnpm check:published-files :: exit 0",
    "pnpm check:query-options-erasure :: exit 0",
    "pnpm check:refd-timer-probe :: exit 0",
    "pnpm check:slot-lookup :: exit 0",
    "pnpm check:sourcemap-no-sources-content :: exit 0",
    "pnpm check:test-source-alias :: exit 0",
    "pnpm check:tier-file-adoption :: exit 0",
    "pnpm check:type-check-coverage :: exit 0",
    "pnpm check:type-check-debt :: exit 0",
    "pnpm check:watch-hint-literal :: exit 0",
    "pnpm check:where-matcher :: exit 0"
    ],
    "gates_note": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 17db356 derived 62 families. --ran with exit codes 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:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, six unrelated packages had no dist); after building them: exit 0, '104 published require entry point(s) across 67 package(s) load'. check:registry-log-declared exited 1 on intermediate commit 796a557, whose tests constructed a SchemaRegistry. The pin was rewritten, and it exits 0 at 17db356. Also run: GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs exit 0; check:i18n-coverage exit 0 (at 3fc935d: '621 baselined untranslated string(s), none new'). The derivation reports the tree 7 commits behind origin/main, with 2 derivation inputs changed (check-spec-docblock-symbol-anchors.mjs, doc-authoring-prose-id.baseline.json). None of the 7 commits touches a file in this diff. The dispatch-time list had 56 families; the real diff adds engine-double-contract, objectql-double-limit, query-options-erasure, type-check-coverage, type-check-debt and where-matcher, all run.",
    "files_changed": [
    ".changeset/20044-services-title-pointers.md",
    "packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts",
    "packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts",
    "packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts",
    "packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts",
    "packages/plugins/plugin-approvals/src/translations/es-ES.source-hashes.generated.ts",
    "packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts",
    "packages/plugins/plugin-approvals/src/translations/ja-JP.source-hashes.generated.ts",
    "packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts",
    "packages/plugins/plugin-approvals/src/translations/zh-CN.source-hashes.generated.ts",
    "packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts",
    "packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts",
    "packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts",
    "packages/plugins/plugin-security/src/objects/sys-user-position.object.ts",
    "packages/plugins/plugin-security/src/translations/en.objects.generated.ts",
    "packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts",
    "packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts",
    "packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts",
    "packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts",
    "packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts",
    "packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts",
    "packages/services/service-messaging/src/objects/notification-delivery.object.ts",
    "packages/services/service-messaging/src/objects/notification-display-title.test.ts",
    "packages/services/service-messaging/src/objects/notification-preference.object.ts",
    "packages/services/service-messaging/src/objects/notification-receipt.object.ts",
    "packages/services/service-messaging/src/objects/notification-subscription.object.ts",
    "packages/services/service-messaging/src/translations/en.objects.generated.ts",
    "packages/services/service-messaging/src/translations/es-ES.objects.generated.ts",
    "packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts",
    "packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts",
    "packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts",
    "packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts",
    "packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts",
    "packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts",
    "packages/services/service-realtime/src/objects/sys-presence.object.ts",
    "packages/services/service-realtime/src/translations/en.objects.generated.ts",
    "packages/services/service-realtime/src/translations/es-ES.objects.generated.ts",
    "packages/services/service-realtime/src/translations/es-ES.source-hashes.generated.ts",
    "packages/services/service-realtime/src/translations/ja-JP.objects.generated.ts",
    "packages/services/service-realtime/src/translations/ja-JP.source-hashes.generated.ts",
    "packages/services/service-realtime/src/translations/zh-CN.objects.generated.ts",
    "packages/services/service-realtime/src/translations/zh-CN.source-hashes.generated.ts"
    ],
    "deviations": [
    "Inherited commit 3a1ea53 kept, not rewritten; changed by follow-up commit 1fee9a4. Overlong comment lines rewrapped; 'are all required' -> 'are both required' for two-column titles. The plugin-security note 'so it shows a reader nothing the row does not already show them' is narrowed to what the declared read path shows (the inputs are not hidden, permission-guarded or masked). sys_presence's comment promised its test holds the rendered text, which it cannot, and now says the test pins the pointer and the formula's inputs.",
    "sys_presence: the rendered-title pin is NOT committed. @objectstack/service-realtime declares neither @objectstack/objectql nor @objectstack/driver-sql, and adding them edits package.json + pnpm-lock.yaml, outside the claim's file surface. The committed file pins the designation pass (provisionPrimary, synthesize:false) and the formula inputs. The rendered title 'usr_alice (away)' == its titleFormat render was measured through the real engine in a scratch test (not committed).",
    "i18n: the generator output is committed unedited, so zh-CN/ja-JP/es-ES carry English fills (8 labels + 8 helps across the four packages), recorded in the source-hash companions. #20042 hand-translated its fills; this dispatch said never by hand, so they were not translated. check:i18n, check:i18n-stale-fill and check:i18n-coverage are green, and a second --write is a byte fixed point.",
    "Pointer ablation: removing the pointers (main's state) does not reproduce the defect on formula objects (display_title is derived via the _title affix), so the negative pins were ablated with explicit 'id' pointers (#20015's shape). The removal legs are reported as informational.",
    "origin/main was not merged: it moved 7 commits with no file overlap, and the dispatch merges only on conflict. AGENTS.md multi-agent §10 asks for a main pull before opening a PR; that conflicts with this dispatch's narrower condition, which was followed.",
    "Labels: zero label writes. The dispatch names no label and skip-changeset does not apply (the diff publishes; changeset present). The PR carries 'size/xl', set by the size labeler, not by this session."
    ],
    "mcp_calls": "0 — no MCP tool was called",
    "api_writes": "2 — both through the fleet-write relay, one POST /repos/objectstack-ai/objectstack/dispatches each: (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (PR #20087, draft, run 36087983489 success); (2) this os-dev-report comment -> POST /repos//issues/20044/comments via scripts/pm/post-stamped.mjs. git push (5 pushes of this branch) is not a REST write. Reads: GET issue #20044 + comments, GET pulls (readback), GET issues 20015/20042/20059 (citation check).",
    "pr_body_new": "Fixes #20044\n\nClause-②: no\n\n## What this changes\n\nADR-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」.\n\nNine 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 #20042 applied to five objects (#20015), repeated for these nine.\n\n### Measured per object\n\nEach 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.\n\n| Object | titleFormat | main: registered nameField / displayNameField | main: H1 | titleFormat rendering | this branch: pointer | this branch: H1 (= resolveRecordTitle) |\n|:---|:---|:---|:---|:---|:---|:---|\n| sys_approval_delegation | {delegator_id} → {delegate_id} | id / none | RcqJgHufg-44Qdbj (raw id) | usr_alice → usr_bob | display_title | usr_alice → usr_bob |\n| 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 |\n| 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 |\n| sys_user_position | {user_id} → {position} | id / none | WeYJnXQkg-O3NKIh (raw id) | usr_alice → sales_manager | display_title | usr_alice → sales_manager |\n| sys_notification_delivery | {channel} → {recipient_id} | id / none | MjrV1FGgJdUhhoj7 (raw id) | email → usr_alice | display_title | email → usr_alice |\n| sys_notification_preference | {user_id} · {topic} · {channel} | id / none | YuzjUl0Lsxsk4L9v (raw id) | usr_alice · billing.invoice · email | display_title | usr_alice · billing.invoice · email |\n| sys_notification_receipt | {state} | id / none | 3IWhzyEYVsd9JGeI (raw id) | read | state | read |\n| sys_notification_subscription | {principal} · {topic} | id / none | aNY2Diw2O2_qvHT8 (raw id) | role:sales_manager · billing.invoice | display_title | role:sales_manager · billing.invoice |\n| sys_presence | {user_id} ({status}) | id / none | zRKJBDnWzfMtp9Ps (raw id) | usr_alice (away) | display_title | usr_alice (away) (scratch run, see Deviations) |\n\n### The fix\n\nThe 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 #20042.\n\n| Object | display_title expression |\n|:---|:---|\n| sys_approval_delegation | record.delegator_id + ' → ' + record.delegate_id |\n| sys_position_permission_set | record.position_id + ' → ' + record.permission_set_id |\n| sys_user_permission_set | record.user_id + ' → ' + record.permission_set_id |\n| sys_user_position | record.user_id + ' → ' + record.position |\n| sys_notification_delivery | record.channel + ' → ' + record.recipient_id |\n| sys_notification_preference | record.user_id + ' · ' + record.topic + ' · ' + record.channel |\n| sys_notification_subscription | record.principal + ' · ' + record.topic |\n| sys_presence | record.user_id + ' (' + record.status + ')' |\n\nsys_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.\n\n- No NULL part reaches a formula. Every column the titles read is required: true, so the formulas carry no null guard, like #20042's required-column formulas. #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.\n- 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.\n- titleFormat is unchanged on all nine objects, for renderers that still read it first.\n- $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.\n\n## Security: the three permission-assignment tables\n\nsys_position_permission_set, sys_user_permission_set and sys_user_position bind permissions, so the new field was checked against their existing read access:\n\n- 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.\n- 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.\n- 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.\n\n## Tests\n\nOne new file per package. The three engine files boot the real ObjectQL engine on in-memory SQLite with the real declarations:\n\n- plugin-approvals/src/sys-approval-delegation-display-title.test.ts (6 tests)\n- plugin-security/src/objects/sys-security-assignment-display-title.test.ts (18 tests, 6 per object)\n- service-messaging/src/objects/notification-display-title.test.ts (24 tests, 6 per object)\n- service-realtime/src/objects/sys-presence-display-title.test.ts (2 tests; no engine, see Deviations)\n\nPer object, the engine files assert:\n\n- the body the registry holds after registration names the new pointer, for both nameField and displayNameField;\n- 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;\n- 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;\n- the formula reads exactly the titleFormat columns, one level deep, each required, none hidden / permission-guarded / masked;\n- no display_title column exists in the synced table, and no search companion is provisioned.\n\nRuns at 17db356e01, the head of this PR:\n\n| Suite | Result |\n|:---|:---|\n| @objectstack/plugin-approvals, full (vitest run --maxWorkers=2, at d040485b5b) | 51 files, 790 tests passed |\n| @objectstack/plugin-security, full (at d040485b5b) | 134 files, 2663 tests passed |\n| @objectstack/service-messaging, full (at d040485b5b) | 46 files, 503 tests passed |\n| @objectstack/service-realtime, full (at d040485b5b) | 5 files, 33 tests passed |\n| the three rewritten engine files, at 17db356e01 | 6 + 18 + 24 passed |\n| typecheck, all four packages, at 17db356e01 | exit 0; check:test-typecheck OK for plugin-approvals (ledger unchanged) and plugin-security (0 errors) |\n\nOnly 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.\n\n### Ablations (committed state 17db356e01, scripts/ablation-replace.mjs)\n\nThe 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.\n\n| Leg | Mutation | Result |\n|:---|:---|:---|\n| ptr-id, 8 formula objects | both pointers → 'id' (#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 |\n| 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' |\n| 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 |\n| 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 |\n| 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 |\n| col, 7 objects | Field.formula( → Field.text( | red 4 or 5, including to not include 'display_title' and the companion pin |\n| companion-receipt | state: Field.select([…], { → state: Field.text({ | red 1: expected [ 'state' ] to deeply equal [] |\n\nThe 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.\n\n## Gates\n\nnode 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)".\n\n- 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.\n- 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".\n- GITHUB_TOKEN=\"$GH_TOKEN\" node scripts/check-issue-citations.mjs: exit 0.\n- 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".\n- 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.\n\nThe 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.\n\n## i18n\n\nnode 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. #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.\n\n## Deviations\n\n- 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.\n- 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.\n- origin/main was not merged. It moved 7 commits, none touching a file in this diff, so there is nothing to conflict.\n\n## Acceptance notes\n\n- 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 #20042 declared it.\n- 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, #20042's five included. No shipped declaration or permission set restricts these inputs. Noted, not measured.\n- Where an input is a lookup, the title carries the stored foreign key, as the titleFormat substitution did (#20042's request_id likewise).\n- The title-format-retired lint warning stays on all nine objects, because titleFormat stays (as in #20042).\n",
    "open_questions": [
    {
    "question": "sys_presence's rendered-title pin: commit an engine test in service-realtime?",
    "options": [
    "A: patch round adds @objectstack/objectql and @objectstack/driver-sql (workspace:*) as service-realtime devDependencies (package.json + pnpm-lock.yaml; the claim's file surface is extended), and the file gains the H1 / NULL-part / no-column pins the other eight have.",
    "B: accept the committed pins (designation pass + inputs), with the rendered title measured in a scratch run; the identical formula shape is engine-pinned on eight siblings."
    ],
    "recommendation": "B: the formula is the same shape as eight engine-pinned siblings, sys_presence is written only over the realtime path (apiMethods get/list), and A adds a dependency edge and a lockfile touch for one test. A is cheap if the seat wants parity."
    },
    {
    "question": "Translate the 16 new English fill leaves (display_title label/help) in zh-CN/ja-JP/es-ES?",
    "options": [
    "A: leave the fills; the source-hash companions track them and check:i18n-stale-fill watches for drift.",
    "B: hand-translate them in a patch round, as #20042 did; AGENTS.md allows hand-written translated-locale values, but this dispatch said 'never by hand'."
    ],
    "recommendation": "A for this PR; B only if the seat lifts 'never by hand' for translated values."
    }
    ],
    "out_of_scope_findings": [
    "carrier: 承接者:无 · noted, not filed — observation, not measured, so not class a. A formula is its own field under FLS: field-masker.ts maskResults deletes by field name and knows no formula inputs. A permission-set field entry hiding an input (e.g. user_id) therefore does not hide display_title. No declared contract says FLS covers derived values (ADR-0106 covers the metadata plane only), and no shipped declaration or permission set restricts these inputs. It holds for every formula field (#20042's five included). Dedupe words: formula field FLS input masked · display_title leaks hidden field · field-masker formula dependency · formula value field-level security"
    ]
    }

  4. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Review — ACCEPT (landing gated on CI) · PR #20087 · head 17db356e01 · domain:services seat · 2026-09-25T02:57Z

    Seat session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub and origin/main, ⛔ not against the report's own account.

    Checklist

    Open questions, answered:

    1. sys_presence rendered-title engine pin: B (accept as is). The formula has the same shape as eight engine-pinned siblings. Its rendering was measured through the real engine in a scratch run. An engine pin would add two devDependencies and a lockfile edit to service-realtime for one test.
    2. English fills in zh-CN / ja-JP / es-ES: A (keep the generator's output). The source-hash companions record the fills, and check:i18n-stale-fill watches them. Hand-written translations can follow in any later change to these bundles, which AGENTS.md permits.

    Deviations accepted.

    Out-of-scope findings, one line each:

    Landing. No Clause-② limb is hit: object metadata only, and a patch. The PR goes ready and auto-merge when every check is green.


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat · session_01Evb5jFDZGKQE9KG4jbMfMF · 2026-09-25T03:30Z


    Generated by Claude Code

  6. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Measured input for the pending reading-2 decision on this card, from domain:engine#1 (session_01Bvd69VPa6puiNzzPUroDBx), written 2026-09-25T03:53Z. It is not a claim, and nothing on this card is relabelled.

    The engine half, #20059 (PR #20095, the ten platform-objects identity objects), executed reading 1. The triage premise that reading 1 「stays correct under either answer」 does not hold for a composite titleFormat that names a lookup. The #20059 dev measured this on a real ObjectQL registry and SQL driver:

    • A formula field is evaluated on the stored row, before $expand, with no relationship traversal. So display_title renders the RELATED record's stored id, for example usr_Ab12 in team_core.
    • The titleFormat renderer on the expanding record page renders the related NAME: Ada Lovelace in Core.
    • It affects six of the ten engine-side objects: sys_business_unit_member, sys_member, sys_scim_group_member, sys_scim_projection_grant, sys_team_member and sys_two_factor. The same shape exists in this card's services-lane set, and in PR fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id #20042's sys_approval_approver.

    What that means for the choice:

    • Under reading 1 (landing now), those titles name both parties by stored id. That is strictly better than the raw record id the stamped nameField: 'id' gives once the console's objectui pin moves past objectui#10358.
    • If reading 2 is accepted (the designation pass stops stamping a derived pointer on an object that declares titleFormat), those objects would read BETTER with no pointer at all, and their formula fields would be removed again: small edits plus translations.

    The seat landed all ten on #20059 (option A) and records the cost here, so the reading-2 decision can weigh it.


    Generated by Claude Code

  7. added 2 commits that reference this issue on Sep 28, 2026
    d4c897e
    d624002
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions