Repository navigation
Commit d4c897e
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)
Fixes #20044
Clause-②: no
## What this changes
ADR-0079 resolves a record's title as `nameField`, then
`displayNameField`, then a derivation. An explicit `nameField` takes
precedence over the render-only `titleFormat`. The `titleFormat`
describe (`packages/spec/src/data/object.zod.ts`) states the migration:
「an explicit nameField now takes precedence … Migrate a single-field
title to nameField, a composite to a formula field designated as
nameField」.
Nine services-lane objects declared a `titleFormat` and no pointer. At
registration the registry's designate-only pass (`provisionPrimary(…, {
synthesize: false })`) derives the first title-eligible field, which on
all nine is `id`, and stamps it as `nameField`. A `/meta` read serves
that stamp as if the author had written it. objectstack-ai/objectui#9436
(landed as objectstack-ai/objectui#10358) makes the record page honour
the declared pointer, so each record page's H1 becomes the raw id once
the console pin moves past it. This is the same remedy PR #20042 applied
to five objects (#20015), repeated for these nine.
### Measured per object
Each declaration was registered in a real `ObjectQL` registry on
in-memory SQLite. One representative row was seeded and read back
through `findOne`, and its H1 was read under ADR-0079's order next to
the `titleFormat` rendering. The "main" columns were measured at
`b76aad5f6f`, with the declarations as they stand on `main`. The "this
branch" columns come from the committed pins.
| Object | `titleFormat` | main: registered `nameField` /
`displayNameField` | main: H1 | `titleFormat` rendering | this branch:
pointer | this branch: H1 (= `resolveRecordTitle`) |
|:---|:---|:---|:---|:---|:---|:---|
| `sys_approval_delegation` | `{delegator_id} → {delegate_id}` | `id` /
none | `RcqJgHufg-44Qdbj` (raw id) | `usr_alice → usr_bob` |
`display_title` | `usr_alice → usr_bob` |
| `sys_position_permission_set` | `{position_id} → {permission_set_id}`
| `id` / none | `2lvPKcxu8uDzPCzk` (raw id) | `pos_sales → ps_crm_edit`
| `display_title` | `pos_sales → ps_crm_edit` |
| `sys_user_permission_set` | `{user_id} → {permission_set_id}` | `id` /
none | `F06WPbxwwcDPtxYt` (raw id) | `usr_alice → ps_crm_edit` |
`display_title` | `usr_alice → ps_crm_edit` |
| `sys_user_position` | `{user_id} → {position}` | `id` / none |
`WeYJnXQkg-O3NKIh` (raw id) | `usr_alice → sales_manager` |
`display_title` | `usr_alice → sales_manager` |
| `sys_notification_delivery` | `{channel} → {recipient_id}` | `id` /
none | `MjrV1FGgJdUhhoj7` (raw id) | `email → usr_alice` |
`display_title` | `email → usr_alice` |
| `sys_notification_preference` | `{user_id} · {topic} · {channel}` |
`id` / none | `YuzjUl0Lsxsk4L9v` (raw id) | `usr_alice · billing.invoice
· email` | `display_title` | `usr_alice · billing.invoice · email` |
| `sys_notification_receipt` | `{state}` | `id` / none |
`3IWhzyEYVsd9JGeI` (raw id) | `read` | `state` | `read` |
| `sys_notification_subscription` | `{principal} · {topic}` | `id` /
none | `aNY2Diw2O2_qvHT8` (raw id) | `role:sales_manager ·
billing.invoice` | `display_title` | `role:sales_manager ·
billing.invoice` |
| `sys_presence` | `{user_id} ({status})` | `id` / none |
`zRKJBDnWzfMtp9Ps` (raw id) | `usr_alice (away)` | `display_title` |
`usr_alice (away)` (scratch run, see Deviations) |
### The fix
The eight composites each declare `display_title`, a formula field with
`returnType: 'text'` over the columns their `titleFormat` names.
`nameField` and the `displayNameField` mirror point at it, as in #20042.
| Object | `display_title` expression |
|:---|:---|
| `sys_approval_delegation` | `record.delegator_id + ' → ' +
record.delegate_id` |
| `sys_position_permission_set` | `record.position_id + ' → ' +
record.permission_set_id` |
| `sys_user_permission_set` | `record.user_id + ' → ' +
record.permission_set_id` |
| `sys_user_position` | `record.user_id + ' → ' + record.position` |
| `sys_notification_delivery` | `record.channel + ' → ' +
record.recipient_id` |
| `sys_notification_preference` | `record.user_id + ' · ' + record.topic
+ ' · ' + record.channel` |
| `sys_notification_subscription` | `record.principal + ' · ' +
record.topic` |
| `sys_presence` | `record.user_id + ' (' + record.status + ')'` |
`sys_notification_receipt`'s title is the single column `{state}`, so
`nameField` and `displayNameField` name `state` directly. That is the
describe's migration for a single-field title. An explicit pointer is
honoured whatever the field's type (ADR-0079 D4, `resolveDisplayField`).
`select` is kept out of derivation only, which is why the pass skipped
`state` and stamped `id`.
- **No NULL part reaches a formula.** Every column the titles read is
`required: true`, so the formulas carry no null guard, like #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.
- **No stored column.** A formula is computed on read, and the synced
tables carry no `display_title` column. No search-companion column
appears either: a formula is never a companion source, and `select` is
not title text. The pin runs `provisionSearchCompanion` over the
registered body, the step a pinyin-enabled registry runs, and
`resolveSearchCompanionSources` answers `[]`. No migration runs.
- **`titleFormat` is unchanged** on all nine objects, for renderers that
still read it first.
- **`$search` scans the same fields.** A formula is never a search
target, and neither was `id`. On `sys_notification_receipt`, `state` (a
`select`) was already in the auto-default set and now leads it. The lead
changes the order only, never the members, and none of the nine declares
`searchableFields`.
## Security: the three permission-assignment tables
`sys_position_permission_set`, `sys_user_permission_set` and
`sys_user_position` bind permissions, so the new field was checked
against their existing read access:
- Each formula reads only its own row's columns: the foreign keys
(`position_id`, `permission_set_id`, `user_id`) and the `position` name.
It never reads a field of the record a key points at, so it never
traverses a lookup the reader may not see.
- None of those columns is `hidden`, guarded by `requiredPermissions` or
masked (`maskingRule`). The pin asserts all three per column, and each
was ablated on its own. They were already served to every reader of the
row, and they appear in `highlightFields` and `titleFormat`.
- No row scope, permission set, `apiMethods`, `managedBy` or
`userActions` entry changes. `display_title` is read-only. The shipped
permission sets grant these objects object-level access only
(`default-permission-sets.ts`) and carry no field entries.
## Tests
One new file per package. The three engine files boot the real
`ObjectQL` engine on in-memory SQLite with the real declarations:
- `plugin-approvals/src/sys-approval-delegation-display-title.test.ts`
(6 tests)
-
`plugin-security/src/objects/sys-security-assignment-display-title.test.ts`
(18 tests, 6 per object)
- `service-messaging/src/objects/notification-display-title.test.ts` (24
tests, 6 per object)
- `service-realtime/src/objects/sys-presence-display-title.test.ts` (2
tests; no engine, see Deviations)
Per object, the engine files assert:
- the body the registry holds after registration names the new pointer,
for both `nameField` and `displayNameField`;
- a seeded row's H1 is the literal `titleFormat` text, is not the row's
id, equals the `titleFormat` rendering of that row, and
`resolveRecordTitle` agrees;
- a row missing a title column is refused (`VALIDATION_FAILED`, field
named, code `required`), or is filled from the declared default and
titled by it;
- the formula reads exactly the `titleFormat` columns, one level deep,
each `required`, none `hidden` / permission-guarded / masked;
- no `display_title` column exists in the synced table, and no search
companion is provisioned.
Runs at `17db356e01`, the head of this PR:
| Suite | Result |
|:---|:---|
| `@objectstack/plugin-approvals`, full (`vitest run --maxWorkers=2`, at
`d040485b5b`) | 51 files, 790 tests passed |
| `@objectstack/plugin-security`, full (at `d040485b5b`) | 134 files,
2663 tests passed |
| `@objectstack/service-messaging`, full (at `d040485b5b`) | 46 files,
503 tests passed |
| `@objectstack/service-realtime`, full (at `d040485b5b`) | 5 files, 33
tests passed |
| the three rewritten engine files, at `17db356e01` | 6 + 18 + 24 passed
|
| `typecheck`, all four packages, at `17db356e01` | exit 0;
`check:test-typecheck` OK for plugin-approvals (ledger unchanged) and
plugin-security (0 errors) |
Only the three engine test files changed between `d040485b5b` and
`17db356e01`. `tsc --listFiles` finds every new test file inside a
typecheck program: plugin-approvals and plugin-security
`tsconfig.test.json`, service-messaging and service-realtime
`tsconfig.json`.
### Ablations (committed state `17db356e01`,
`scripts/ablation-replace.mjs`)
The object files are imported relatively from source, so no `dist/` sits
on the resolution path and no rebuild is involved. Every leg printed `ok
mutation landed` before its run and `ok restored: blob == HEAD` after
it. The tree equalled `HEAD` after each of the 33 legs.
| Leg | Mutation | Result |
|:---|:---|:---|
| ptr-id, 8 formula objects | both pointers → `'id'` (#20015's shape) |
red on the pointer and H1 tests of that object, e.g. `expected
'22o5f2s01PYV_LAL' to be 'usr_alice → usr_bob'`, `expected
'Hc3c8fIEvdtL9EZZ' to be 'pos_sales → ps_crm_edit'`, `expected
'1-dAWCH9Y12B-1z4' to be 'email → usr_alice'`; presence: `expected 'id'
to be 'display_title'` from the designation pass |
| ptr-removed, receipt | both pointers deleted (`main`'s state) | red 3:
`expected 'id' to be 'state'`, `expected 'fKk2vvHoRePGTkDD' to be
'read'`, `expected 'nwVM_6ig0mkb28sJ' to be 'delivered'` |
| ptr-removed, delegation and presence | both pointers deleted | red 1
each, on the `displayNameField` mirror only (`expected undefined to be
'display_title'`); the H1 stays right, see Acceptance notes |
| req, 8 objects | one title column `required: true` → `false` | red on
the refusal test (`promise resolved … instead of rejecting`) and the
inputs test (`recipient_id: expected false to be true`); presence: the
inputs test |
| withheld, 6 legs | `hidden: true` on an input (delegation, delivery,
presence, `sys_position_permission_set`); `requiredPermissions` on
`sys_user_permission_set.user_id`; `maskingRule: 'name'` on
`sys_user_position.position` | red on the inputs test each time, e.g.
`user_id: expected [ 'view_assignment_subjects' ] to deeply equal []`,
`position: expected 'name' to be undefined` |
| col, 7 objects | `Field.formula(` → `Field.text(` | red 4 or 5,
including `to not include 'display_title'` and the companion pin |
| companion-receipt | `state: Field.select([…], {` → `state:
Field.text({` | red 1: `expected [ 'state' ] to deeply equal []` |
The first version of the companion pin read the synced table's columns
for `__search`. The engine the tests boot builds its registry with the
companion off (it follows `OS_SEARCH_PINYIN_ENABLED`), so that pin could
not fail, and the companion-receipt leg stayed green. It was replaced by
the `provisionSearchCompanion` form above, which goes red on that leg.
## Gates
`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` from the real diff derived 62 families. All 62 were run at
`17db356e01`, and `--ran` (with an exit code recorded per family)
reconciles: "62 derived famil(ies) accounted for — 62 run, 0
NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of
them is 3)".
- `check:registry-log-declared` first went red on an intermediate commit
whose tests constructed a `SchemaRegistry`. That requires an
`OS_REGISTRY_LOG` declaration in three `vitest.config.ts` files outside
this change. The pin was rewritten not to construct one, and the gate is
green at `17db356e01`.
- `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (six
unrelated packages had no `dist/`). After building them it passed: "104
published require entry point(s) across 67 package(s) load".
- `GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs`:
exit 0.
- `check:i18n-coverage` (at `3fc935d944`, the bundle commit; nothing
after it touches a bundle or a declaration): OK, "13 config(s), 621
baselined untranslated string(s), none new".
- Narrowed eslint over the 41 changed `.ts` files (`--no-inline-config
--format json`): 41 files, 0 errors, 0 warnings, 0 ignored. The config
never enables type-aware linting (`eslint.config.mjs` around line 328:
no `parserOptions.project`), so a file's verdict depends on that file
alone. The repo-wide `pnpm lint` is CI's.
The derivation notes the tree is 7 commits behind `origin/main`. Two
gate inputs changed across that range:
`scripts/check-spec-docblock-symbol-anchors.mjs` and
`scripts/doc-authoring-prose-id.baseline.json`. None of those commits
touches a file in this diff.
## i18n
`node scripts/check-i18n-bundles.mjs --write --filter=…` regenerated the
four packages' bundles, and its output is committed unedited. The
English bundles gain `display_title`'s label and help. The zh-CN, ja-JP
and es-ES bundles carry the generator's English fill, and the
source-hash companions record those fills. A second `--write` is a
byte-for-byte fixed point. `check:i18n` and `check:i18n-stale-fill` are
green. #20042 translated its new leaves by hand, but this dispatch said
the bundles are regenerated by tooling and never edited by hand. The
translated values can be hand-written in a later change, which AGENTS.md
allows.
## Deviations
- **`sys_presence`'s rendered-title pin is not committed.**
`@objectstack/service-realtime` declares neither `@objectstack/objectql`
nor `@objectstack/driver-sql`. An engine test there needs both as
devDependencies, which edits `package.json` and `pnpm-lock.yaml`,
outside the claimed file surface. The committed file pins the
designation pass (`provisionPrimary`, the step the registry runs) and
the formula's inputs. The rendered title was measured through the real
engine in a scratch run, not committed: `usr_alice (away)`, equal to the
`titleFormat` rendering.
- **The inherited commit (`3a1ea534a4`) was kept and amended by a
follow-up commit, not rewritten.** Its formulas were re-derived against
each `titleFormat` and all nine match. `sys_notification_receipt`'s
`titleFormat` is exactly `{state}`, so `state` is its one field. The
follow-up rewraps overlong comment lines and corrects "all required" for
two-column titles. It narrows the plugin-security note to what the
declared read path shows, and replaces a comment that promised the
presence test holds the rendered text.
- **`origin/main` was not merged.** It moved 7 commits, none touching a
file in this diff, so there is nothing to conflict.
## Acceptance notes
- Removing both pointers from a formula object does not bring the defect
back: `display_title` then wins derivation tier 2 (the `_title` affix).
Measured on `sys_approval_delegation` and `sys_presence`, where only the
`displayNameField` mirror went missing. The explicit pointer is kept
because the describe prescribes it and #20042 declared it.
- A formula is a field of its own under field-level security. The masker
(`field-masker.ts` `maskResults`) deletes fields by name and knows no
formula inputs. A deployment that hides an input column through a
permission-set field entry therefore does not hide `display_title` with
it. That holds for every formula field, #20042's five included. No
shipped declaration or permission set restricts these inputs. Noted, not
measured.
- Where an input is a lookup, the title carries the stored foreign key,
as the `titleFormat` substitution did (#20042's `request_id` likewise).
- The `title-format-retired` lint warning stays on all nine objects,
because `titleFormat` stays (as in #20042).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3c48234 commit d4c897e
42 files changed
Lines changed: 1095 additions & 0 deletions
File tree
- .changeset
- packages
- plugins
- plugin-approvals/src
- translations
- plugin-security/src
- objects
- translations
- services
- service-messaging/src
- objects
- translations
- service-realtime/src
- objects
- translations
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
| |||
40 | 41 | | |
41 | 42 | | |
42 | 43 | | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
43 | 53 | | |
44 | 54 | | |
45 | 55 | | |
| |||
62 | 72 | | |
63 | 73 | | |
64 | 74 | | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
65 | 86 | | |
66 | 87 | | |
67 | 88 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
302 | 302 | | |
303 | 303 | | |
304 | 304 | | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
305 | 309 | | |
306 | 310 | | |
307 | 311 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
302 | 302 | | |
303 | 303 | | |
304 | 304 | | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
305 | 309 | | |
306 | 310 | | |
307 | 311 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
| 31 | + | |
| 32 | + | |
31 | 33 | | |
32 | 34 | | |
33 | 35 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
302 | 302 | | |
303 | 303 | | |
304 | 304 | | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
305 | 309 | | |
306 | 310 | | |
307 | 311 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| 30 | + | |
| 31 | + | |
30 | 32 | | |
31 | 33 | | |
32 | 34 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
302 | 302 | | |
303 | 303 | | |
304 | 304 | | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
305 | 309 | | |
306 | 310 | | |
307 | 311 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| 21 | + | |
| 22 | + | |
21 | 23 | | |
22 | 24 | | |
23 | 25 | | |
| |||
0 commit comments