Repository navigation
Commit e282f32
fix(service-messaging,metadata-protocol)!: a notification preference's User is a sys_user reference, so its title names the person and the default for every user is an empty User instead of '*' (#25178)
Fixes #24895
Clause-②: yes (narrowing)
Metadata-protocol: none
`sys_notification_preference.user_id` becomes a `sys_user` reference.
The default for every user is an empty User instead of the sentinel
`'*'` (ADR-0118 D1: 「用户 lookup 列中,系统 actor = `null`;不造魔法账号,不存哨兵」). The
record title names the person one hop through the reference, and the
write door refuses an id no user holds.
This PR carries out maintainer ruling A (`6304380498`, 「#380 同意」), with
the execution parameters the seat set in `6331411142`: Q1 A (a
runtime-managed NULL-safe index in `metadata-protocol`), Q2 B (a boot
conversion in `service-messaging`), and `cascade`. The file surface is
the amended claim `6331438059`; the cross-lane declarations are on #6367
(`6331420796`) and #22648 (`6331427821`). It is one PR into 18.x: the
type change, the stored-row conversion, the resolver and the index land
together. A review at `CONTRACT_REVIEW_TIER` is owed before the queue.
## What changes
**`service-messaging`**
- `notification-preference.object.ts`: `user_id` is
`Field.lookup('sys_user', { required: false, searchable: true,
deleteBehavior: 'cascade' })`, with no `maxLength`; a reference's width
is the parent id column's.
- An empty User is the default for every user.
- `cascade` is load-bearing. Under the lookup default `set_null`,
deleting a user empties the reference, and an empty reference now means
everyone. Measured: the person's own mute (`enabled: false`) became the
admin-global mute.
- The title formula reads the person one hop, through the optional read:
`(isBlank(trim(record.user_id.?name.orValue(''))) ?
record.user_id.?email.orValue('All users') :
record.user_id.?name.orValue('')) + ' · ' + topic + ' · ' + channel`.
- The name is used, or the email where the name is blank.
- An EMPTY reference reads `All users`.
- A reference the read cannot resolve (a user that is gone, or one this
reader may not read) leaves the whole title null. It never reads `All
users`, which would name the wrong row.
- `user_id` is read only through the hop
(`traversal-bare-and-traversed`). `titleFormat` is unchanged.
- `preference-resolver.ts`: the admin-global default is the row whose
`user_id` is empty. `null`, absent and `''` all count, the three
spellings the engine's own reference check reads as "no link".
- A stored `'*'` matches no recipient: the resolver carries no `'*'`
tolerance.
- `WILDCARD = '*'` stays for topic and channel only.
- `preference-user-conversion.ts` (new) is armed by the plugin at
`kernel:ready`, after table provisioning. It rewrites every stored
`user_id = '*'` to an empty User, across organizations.
- Each write threads the row's own organization, so it needs no audit
bypass.
- It is idempotent: a second run reads nothing to convert and writes
nothing.
- A `'*'` row whose (organization, topic, channel) already has an
empty-User default is kept as stored and reported once at `error`,
naming the row (ADR-0120 D4: refuse, never keep-one). A missing table is
a silent `absent`.
- Translations: the `en` bundle is regenerated. The `user_id` and title
help are retranslated in `es-ES`, `ja-JP` and `zh-CN`; the extract
dropped their copy digests.
**`metadata-protocol`** (`domain:engine`, declared on #6367)
- `migrations/notification-preference-identity-index.ts` (new) is the
fourth runtime-managed index tightening, a copy of the `sys_setting`
paradigm.
- It rebuilds the declared index under its own name,
`uniq_sys_notification_preference_a22d7d27`, as `(organization_id,
COALESCE(user_id, ''), topic, channel)`.
- The order is probe-first through `partial-index-probe.ts`, over the
`driver-exec.ts` seam.
- Existing duplicates make it refuse to migrate: the previous index
stays, no row is touched, and an `error` line ships the listing query.
- MySQL refuses the key parts (`unsupported`, reported at `error`).
- A missing table is a silent no-op.
- `plugin.ts`: the migration is armed inside the existing
`runPlatformMigrations`-gated `kernel:ready` handler, with its own
try/catch. That is the only change in the file.
- `migrations/runtime-index-preflight.ts`: the index's probe joins the
roster `os migrate duplicates` reports.
**`dogfood`**: `upgrade-18x-index-equivalence.dogfood.test.ts` treats
`sys_notification_preference`'s organization unique as runtime-managed,
the way it treats `sys_setting`'s. The full battery found that this PR's
migration rebuilds the index on every boot, so its 18.0
COALESCE-organization spelling no longer survives a HEAD boot byte for
byte: 12 declared composites → 11, and the 18.0.0-birth leg skips the
table. The differ legs (zero `recreate_index`, zero destructive drift)
are unchanged and green.
**`cli`** (`domain:cli`, declared on #22648): no source change. The
probe lives in `metadata-protocol`'s preflight roster, which
`duplicates.ts` already consumes. Only the two roster pins move from
three indexes to four.
**`driver-sql`**: the one pin that spelled `'*'` is flipped, to
`platformKey: { user_id: 'user_platform', … }`. At the driver layer it
is only a key, so either value passes. It was chosen because `'*'` was
this object's admin-global sentinel. A fixture still storing it would
read as documentation that `'*'` is a legal stored `user_id`, which this
PR retires.
## Premises, measured
- On `origin/main` `23540582f`:
- `user_id: Field.text` (`:83`) — 1 hit;
- `const WILDCARD = '*'` (`:69`) — 1 hit;
- `git grep -n -E "user_id: *'\*'" … ':!*.test.ts'` → exit 1, 0 hits.
The positive control without the exclusion finds 3 hits, all tests.
- Real engine (ObjectQL + SqlDriver/better-sqlite3 + `SysUser`):
- the declared index materializes as `(organization_id, user_id, topic,
channel)`;
- two empty-User rows for one key were both accepted;
- control: the same user twice → `409 DUPLICATE_RECORD`.
- The text-column hop reads `null` (the #24775 measurement, re-run). The
lookup hop names the person.
- The column is `varchar(255) nullable` both as text and as a lookup, so
the retype needs no DDL. `required` emits no NOT NULL.
## Pins (all on a real engine unless noted)
`src/preference-user-reference.integration.test.ts` (new, 13 tests):
- Declaration: an optional `sys_user` lookup, `cascade`, no width.
- Write door: `'*'` and `usr_nobody` → `code: 'VALIDATION_FAILED'`,
`mapDataError(...).status === 400`, a finding on `user_id` with code
`reference_not_found`. An update moving a row to `'*'` is refused the
same way. A real user and an empty User are accepted.
- Title:
- `Bea Person · deal.won · inbox`;
- the email where the name is blank;
- `All users · deal.won · inbox`;
- `All users · * · email` (topic default);
- `null` for an id nobody holds.
- Cascade: deleting the user deletes their row; the global default
stays, and no new global default appears.
- Conversion:
- two organizations' `'*'` rows → empty, a real user's row untouched,
`{status:'converted', converted:2}`;
- the second run → `{status:'nothing-to-convert'}` and the rows are
byte-identical;
- no table → `absent`;
- no engine → `no-engine`.
- Index on the SQL driver:
- CONTROL: before the platform migrations, a second global default is
accepted;
- after the real `assembleMetadataProtocol` `kernel:ready` handlers run,
it is refused `DUPLICATE_RECORD` / `409`;
- other organizations, topics and channels, and a user's own row, are
still accepted;
- a `'*'` row that collides is kept, `reason: 'duplicate'`, and logged
once at `error`.
- driver-memory keys the listed columns and stays NULL-distinct, as it
does for `sys_setting`. No test claims otherwise.
- Resolver end to end: an unconverted `'*'` mute stops nobody; after the
conversion it mutes every user.
Other new pins:
- `messaging-service-plugin.test.ts`: the plugin arms the conversion. A
`'*'` row becomes empty on `kernel:ready`, a second ready changes
nothing, and one info line is logged.
-
`metadata-protocol/src/migrations/notification-preference-identity-index.test.ts`
(new, real SQLite):
- before and after, per key, `''` and NULL in one bucket;
- the declared name is reused, idempotent, the 18.0
COALESCE-organization form is rebuilt;
- refuse-on-duplicates keeps the previous index and deletes nothing;
- the `error` text and the listing query;
- absent table, MySQL `unsupported`, and an unclassified failure.
-
`metadata-protocol/src/plugin.notification-preference-index-armed.test.ts`
(new): armed under `runPlatformMigrations`, and absent without it.
- `runtime-index-preflight.test.ts`: the preference probe reports
`blocked` with the folded key, and the roster and statement-identity
pins grow.
## Pins reversed (not deleted)
- `preference-resolver.test.ts`:
- the fixture's default user is the empty reference instead of `'*'`;
- "per-user row overrides the admin-global default" now uses an empty
User;
- new: every engine spelling of empty is the global default, and a
stored `'*'` is not, with its control.
- `objects/notification-display-title.test.ts`: the preference case
(raw-id title `usr_alice · billing.invoice · email`, `user_id` required)
leaves the titleFormat-parity table. It is reversed in the new
integration file: a person title, and an empty User accepted and titled
`All users`. Delivery and receipt keep every assertion.
- `objects/notification-keyed-text-bounds.test.ts`: `user_id` was
bounded to 255. It is now a lookup with no `maxLength`.
- `driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts`:
`'*'` → `'user_platform'` (above).
- `cli/src/commands/migrate/duplicates.contract.test.ts` and
`.integration.test.ts`: the roster is four indexes.
## Ablations (one-time; committed fix first; `node
scripts/ablation-replace.mjs` wrap mode; every leg restored to `blob ==
HEAD`, `git diff HEAD` empty)
| leg | mutation | result |
|---|---|---|
| A1 resolver | `isGlobalDefaultUser` → `return false;` | red: 4 failed
of 40 (`preference-resolver.test.ts` ×3, resolver end-to-end ×1) |
| A2 conversion | the update writes `LEGACY_WILDCARD_USER` instead of
`null` | red: 4 failed of 37 (plugin arming, conversion, collision,
resolver end-to-end) |
| A3 cascade | `deleteBehavior: 'cascade'` deleted | red: 2 failed of 13
(declaration, cascade) |
| A4 arming | the `ensureNotificationPreferenceIdentityIndex` call in
`plugin.ts` neutralised | red on both sides: integration 2 failed of 13
(`409` refusal, collision), arming pin 1 failed of 2 |
## Outside the claim's letter, forced by a gate
The conversion's write is one new write call site on the application
surface. `check-tenant-audit-census` holds
`content/docs/permissions/tenant-audit-census.mdx` and
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` to the
tree.
- I ran the generator (`node scripts/tenant-audit-census.mjs --write`)
and updated the page's seven hand-written counts: 236 → 237 sites, 159 →
160 decidable, 134 → 135 elevated, and 50 → 51 const-named.
- The site is written so the census can place it fully: a same-file
`const` typed `typeof PREFERENCE_OBJECT`, and an inline context carrying
`isSystem` and the row's `tenantId`. No undecidable count moves.
Declared since, by the seat:
- the empty-state registry row: #6017 `6334445279`;
- the dogfood fixture: #22648 `6334455088`;
- the tenant-audit census docs: #6023 `6334465051`;
- `runtime-index-preflight.ts` and the `driver-sql` fixture: #6367
`6334470965`.
## Acceptance notes
- The `All users` literal is English. Formula string literals are not
translated; the field help is.
- `docs/adr/0030-notification-platform-convergence.md` (P2 row:
"admin-global `*` defaults") records history on a governed surface and
is left as is.
`docs/qa/platform-checklist/areas/integration-system.json:1668` still
says "'*' wildcards + admin-global default". That is still true of topic
and channel, and the user half reads stale. Carrier: none.
- `sys_notification_delivery.display_title` has the same shape (`channel
→ recipient_id`). Per ruling item 5 it is the seat's separate card, not
this PR.
- MySQL/MariaDB and driver-memory keep the declared, NULL-distinct index
(`unsupported` is reported at `error` on MySQL), exactly as for
`sys_setting`.
## The empty-state registry row (declared on #6017 `6334445279`)
`packages/spec/scripts/liveness/empty-state-registry.mts` carries one
row:
- the file is the preference object, the property is `user_id`, and the
semantics are `scope`;
- the rationale: an empty User selects which users a delivery toggle
covers (the admin-wide default, ruling A `6304380498`) and grants no
data access; a user's own row overrides it, mandatory topics bypass it,
and `cascade` closes the set_null widening;
- the evidence is `preference-resolver.ts`'s `resolveRow`.
`check:empty-state` exits 0 with the row and 1 without it (`UNCLASSIFIED
… notification-preference.object.ts:110 — user_id`), and the file was
restored to `blob == HEAD`. Nothing else in `packages/spec` moves.
## The conversion's error text (`55663d72e`)
The boot conversion reports the engine error's message (a local
`messageOf`), not `operatorFacingErrorText`. The `types` `test:repo`
roster enumerates that helper's callers, which read the raw-SQL seam's
composed fault, and this path issues no raw SQL: it goes through the
data engine, whose SQL driver already redacts statements and bound
values. Putting the helper call back reds exactly that roster test (1
failed of 11); restored.
## Tests (at `d272b23af` unless marked)
- `pnpm --filter @objectstack/service-messaging exec vitest run
--maxWorkers=2` → `Test Files 56 passed (56) · Tests 621 passed (621)`.
- `pnpm --filter @objectstack/service-messaging run typecheck` → clean.
- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2` → `Test Files 254 passed | 3 skipped (257) · Tests 20857
passed | 19 skipped`. Run at `80ad1c40d`; no `metadata-protocol` file
changed after it.
- `pnpm --filter @objectstack/metadata-protocol run typecheck` → clean,
at the same commit.
- `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2
src/commands/migrate/duplicates` (both tiers) → `Test Files 6 passed (6)
· Tests 37 passed (37)`.
- `pnpm --filter @objectstack/driver-sql exec vitest run
src/sql-driver-tenant-scoped-declared-unique.test.ts` → `85 passed`.
- `pnpm --filter @objectstack/driver-sql run typecheck` → clean.
- `pnpm --filter @objectstack/cli run typecheck`: `tsc --noEmit` clean,
and `check:test-typecheck: OK — @objectstack/cli's test layer compiles`.
- Full dogfood battery (the `!` title and the `(narrowing)` arm), the CI
command under the lock: the closure build, then `pnpm --filter
@objectstack/dogfood test`.
- **Final head `55663d72e`, run alone under the lock** (no gate sweep
beside it): `VERDICT command-exit 1 · Test Files 2 failed | 285 passed |
3 skipped (290) · Tests 1 failed | 2391 passed | 21 skipped (2413)`.
Both failures are timeouts that cannot be attributed to this change:
-
`showcase-single-platform-insert-default-organization.dogfood.test.ts`:
the `beforeAll` hook times out at `bootShowcase()`, a full showcase boot
plus sign-in on vitest's default 10 s budget. Sibling showcase files
budget 60–120 s for the same boot. It also timed out in the `f7ad7e74b`
battery and passed in isolation.
- `object-designer-field-reorder.dogfood.test.ts` › "creates and
publishes the object in its declared order": a 5 s test timeout on a
metadata publish path this diff does not touch.
- The earlier `f7ad7e74b` run's two `upgrade-18x-index-equivalence`
failures were this change's consequence and are fixed above. The earlier
narrowed two-file re-run is superseded by this full run.
## Gates
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on this diff → 103 commands. Each was run
with its exit code captured before any pipe.
- 101 of them exited 0.
- `check:type-check-debt` hit the runner's 900 s timeout while queued on
the lock; the re-run → `EXIT=0` ("1 ledger entr(ies) re-measured … none
above its recorded number").
- `check:empty-state` → 1, above.
- `--ran` reconciliation: `103 derived, 103 run, 0 NOT-MEASURED, 0
UNRUN`.
- Fixed during the sweep:
- `check-tenant-audit-census` (the new write site);
- `check:i18n-stale-fill` (retranslated leaves);
- `check:test-source-alias` (module-top imports);
- `check:query-options-erasure` (test options typed, not erased).
- CI runs the full farm.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent a1e51d6 commit e282f32
30 files changed
Lines changed: 1589 additions & 103 deletions
File tree
- .changeset
- content/docs/permissions
- docs/audits
- packages
- cli/src/commands/migrate
- drivers/driver-sql/src
- metadata-protocol/src
- migrations
- qa/dogfood/test
- services/service-messaging/src
- objects
- translations
- spec/scripts/liveness
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
125 | | - | |
| 125 | + | |
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
| |||
187 | 187 | | |
188 | 188 | | |
189 | 189 | | |
190 | | - | |
| 190 | + | |
191 | 191 | | |
192 | | - | |
193 | | - | |
| 192 | + | |
| 193 | + | |
194 | 194 | | |
195 | 195 | | |
196 | 196 | | |
| |||
201 | 201 | | |
202 | 202 | | |
203 | 203 | | |
204 | | - | |
| 204 | + | |
205 | 205 | | |
206 | 206 | | |
207 | 207 | | |
208 | 208 | | |
209 | 209 | | |
210 | | - | |
| 210 | + | |
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
214 | | - | |
| 214 | + | |
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
| |||
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
226 | | - | |
227 | | - | |
| 226 | + | |
| 227 | + | |
228 | 228 | | |
229 | | - | |
| 229 | + | |
230 | 230 | | |
231 | | - | |
| 231 | + | |
232 | 232 | | |
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | | - | |
| 236 | + | |
237 | 237 | | |
238 | 238 | | |
239 | 239 | | |
240 | 240 | | |
241 | 241 | | |
242 | | - | |
| 242 | + | |
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
248 | | - | |
| 248 | + | |
249 | 249 | | |
250 | 250 | | |
251 | 251 | | |
| |||
297 | 297 | | |
298 | 298 | | |
299 | 299 | | |
300 | | - | |
| 300 | + | |
301 | 301 | | |
302 | 302 | | |
303 | 303 | | |
304 | | - | |
305 | | - | |
306 | | - | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
307 | 307 | | |
308 | 308 | | |
309 | 309 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | | - | |
| 39 | + | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
98 | | - | |
99 | | - | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
| |||
221 | 221 | | |
222 | 222 | | |
223 | 223 | | |
| 224 | + | |
224 | 225 | | |
225 | 226 | | |
226 | 227 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
231 | 231 | | |
232 | 232 | | |
233 | 233 | | |
234 | | - | |
| 234 | + | |
235 | 235 | | |
236 | 236 | | |
237 | 237 | | |
| |||
253 | 253 | | |
254 | 254 | | |
255 | 255 | | |
256 | | - | |
| 256 | + | |
257 | 257 | | |
258 | 258 | | |
259 | 259 | | |
260 | 260 | | |
261 | 261 | | |
262 | 262 | | |
263 | 263 | | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
264 | 267 | | |
265 | 268 | | |
266 | 269 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
314 | | - | |
| 314 | + | |
315 | 315 | | |
316 | | - | |
| 316 | + | |
317 | 317 | | |
318 | 318 | | |
319 | 319 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
252 | 252 | | |
253 | 253 | | |
254 | 254 | | |
255 | | - | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
256 | 260 | | |
257 | 261 | | |
258 | 262 | | |
| |||
0 commit comments