Skip to content

Commit e282f32

Browse files
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
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
fix(service-messaging,metadata-protocol)!: a notification preference's User is a `sys_user` reference, so the record title names the person and the write door refuses an id no user holds; the default for every user is an empty User instead of `'*'`
7+
8+
Clause-②: yes (narrowing)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or exported declaration is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What changes is a platform object this package ships and owns, `sys_notification_preference`: its `user_id` column becomes a `sys_user` lookup, and what narrows is the write accept set of that object's rows, which are admin-authored data rather than metadata. No ADR-0087 conversion reaches a stored data row; the stored rows that spelled the retired `'*'` are rewritten by this package itself at boot, and the one-default-per-key index is a runtime migration in `@objectstack/metadata-protocol`. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
**BREAKING** (a platform object's write accept set narrows, and one stored value is rewritten). It changes what a data writer can store in `sys_notification_preference`, not what a metadata author can write, so it ships as `patch` with this banner.
14+
15+
- **FROM:** `sys_notification_preference.user_id` was a required text column holding a user id or the literal `'*'`, which meant "the default for every user". Any string was stored: a mistyped id matched nobody, silently. The record title (Setup → Notification Preferences) read the raw id, for example `ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap · urgent_task · inbox`. Two defaults for every user could be stored for one topic and channel in one organization, and which one applied was unspecified.
16+
- **TO:** `user_id` is an optional `sys_user` lookup (ADR-0118 D1: no sentinel in a user column). An empty User is the default for every user, and a user's own row still overrides it.
17+
- A write that names an id no user holds, `'*'` included, is refused: the REST data door answers `400` with `code: 'VALIDATION_FAILED'` and a finding on `user_id` with `code: 'reference_not_found'`.
18+
- The title names the person: their name, or their email where the name is blank, then the topic and the channel. A row with an empty User reads `All users · TOPIC · CHANNEL`. A row whose User this reader cannot read, or that no longer exists, has no title rather than a misleading one.
19+
- Deleting a user deletes that user's preference rows (`deleteBehavior: 'cascade'`). Under the lookup default (`set_null`) the person's own mute would have become the default for every user.
20+
- At every boot (`kernel:ready`), the messaging service rewrites each stored `user_id = '*'` to an empty User, in every organization. A second run changes nothing. A `'*'` row whose topic and channel already have a default with an empty User is left as stored and reported once at `error`, naming the row; it is never deleted for you.
21+
- On SQLite and PostgreSQL, `@objectstack/metadata-protocol` rebuilds the index `uniq_sys_notification_preference_a22d7d27` at `kernel:ready` as `(organization_id, COALESCE(user_id, ''), topic, channel)`, so a second default for one topic and channel in one organization is refused (`409`). If existing rows already break that rule, it refuses to migrate: the previous index stays, no row is touched, and the boot log and `os migrate duplicates` list the rows. MySQL/MariaDB and driver-memory keep the declared index, which does not constrain empty Users, and the boot log says so on MySQL.
22+
- **Unchanged:** `topic = '*'` and `channel = '*'` are still the topic and channel wildcards. Most-specific-wins resolution, mandatory topics, quiet hours and digests behave as before. A row for a real user is read, matched and titled as before, except for the title text. The physical column is still a `varchar(255)`, so no DDL change is needed for it.
23+
- **The fix for an operator:** nothing, normally; the boot rewrites the old `'*'` rows. If the boot log reports a `'*'` row that shares its topic and channel with a default whose User is already empty, delete the one that should not apply and restart. If it reports that the one-default-per-key index could not be built because two such defaults exist, list them with `os migrate duplicates`, delete one, and restart. A script or import that wrote `user_id: '*'` writes an empty `user_id` (omit it, or send `null`) for the default for every user.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122122

123123
The same holds twice over for the context. An options argument spelled as a
124124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125-
forwarding shim cannot, and **51 of the 236 sites are spelled that way**. A
125+
forwarding shim cannot, and **51 of the 237 sites are spelled that way**. A
126126
context resolved from an inline literal or a local `const` can be tested for
127127
`isSystem`; one arriving from a helper call cannot.
128128

@@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
187187

188188
| carried figure | where it survives | this census |
189189
| :--- | :--- | ---: |
190-
| 175 write call sites | quoted in the merged changeset | **236** |
190+
| 175 write call sites | quoted in the merged changeset | **237** |
191191
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
192-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **159 of 236** decidable, **77** undecidable |
193-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 134 decidably elevated, 0 decidably not, 94 undecidable |
192+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 237** decidable, **77** undecidable |
193+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 135 decidably elevated, 0 decidably not, 94 undecidable |
194194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195195

196196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -201,17 +201,17 @@ at any commit.
201201

202202
Two structural facts do plausibly widen this reading against any hand or regex
203203
one, and both are counted in the generated tables below: the 41 sites reached
204-
through an erased (`any`) receiver, and the 50 that name their object through a
204+
through an erased (`any`) receiver, and the 51 that name their object through a
205205
`const` rather than inline. An instrument that read either the way a person does
206206
would report a smaller number and would not say so.
207207

208208
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
209209
figure has no surviving corroboration anywhere in the tree.** This census reads
210-
134 of 236 (57%) as decidably elevated, with 94 more whose elevation is a
210+
135 of 237 (57%) as decidably elevated, with 94 more whose elevation is a
211211
run-time fact — so the claim is neither confirmed nor refuted, and the honest
212212
answer is that a static reading cannot settle it.
213213

214-
⇒ **Cite `2 / 236`, and say what it is**: the sites whose options argument was
214+
⇒ **Cite `2 / 237`, and say what it is**: the sites whose options argument was
215215
READ and holds no tenant context, against a decidably tenancy-enabled object.
216216
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
217217
without tenant context" — **29 further sites** have an options argument this
@@ -223,29 +223,29 @@ cannot read, and they are neither in nor out.
223223

224224
| what | count |
225225
| :--- | ---: |
226-
| write call sites on the application surface | **236** |
227-
| …whose object name is statically decidable | 159 |
226+
| write call sites on the application surface | **237** |
227+
| …whose object name is statically decidable | 160 |
228228
| …whose object name is chosen at run time | 77 |
229-
| …against an object with tenancy ENABLED | 158 |
229+
| …against an object with tenancy ENABLED | 159 |
230230
| …against an object that declares tenancy off | 1 |
231-
| threading a tenant context | 177 |
231+
| threading a tenant context | 178 |
232232
| PROVABLY carrying none (options read, no context key) | **8** |
233233
| …of those, against a decidably tenancy-enabled object | **2** |
234234
| options argument UNREADABLE — may or may not carry one | 51 |
235235
| …of those, against a decidably tenancy-enabled object | 29 |
236-
| threading a decidably ELEVATED (`isSystem`) context | 134 |
236+
| threading a decidably ELEVATED (`isSystem`) context | 135 |
237237
| threading a context that is decidably NOT elevated | 0 |
238238
| threading a context whose elevation is a run-time fact | 94 |
239239

240240
| how the instrument reached the site | count |
241241
| :--- | ---: |
242-
| receiver carried a readable engine type | 195 |
242+
| receiver carried a readable engine type | 196 |
243243
| receiver erased, placed by the object NAME | 31 |
244244
| receiver erased, placed by an `object: string` PARAMETER | 5 |
245245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246246

247247
| object name spelled inline | 109 |
248-
| object name spelled through a `const` | 50 |
248+
| object name spelled through a `const` | 51 |
249249
| object name is an `object: string` parameter | 7 |
250250
| object name is some other run-time expression | 70 |
251251

@@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
297297
their values are not compared. The reasoning, and the measurement behind it,
298298
are in `scripts/check-tenant-audit-census.mjs`.
299299

300-
Measured on 2026-10-20 at `a454cbcc4`.
300+
Measured on 2026-10-22 at `452e9963c`.
301301

302302
| corpus scale (not enforced) | count |
303303
| :--- | ---: |
304-
| tracked non-test sources scanned | 684 |
305-
| engine-shaped types recognised | 82 |
306-
| declared objects in the registry | 118 |
304+
| tracked non-test sources scanned | 700 |
305+
| engine-shaped types recognised | 86 |
306+
| declared objects in the registry | 117 |
307307
| same-named calls subtracted as non-engine | 169 |
308308

309309
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 236 |
37-
| Object name statically decidable | 159 |
36+
| Write call sites | 237 |
37+
| Object name statically decidable | 160 |
3838
| Object name chosen at run time | 77 |
39-
| Against a tenancy-enabled object | 158 |
39+
| Against a tenancy-enabled object | 159 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 177 |
41+
| Threading a tenant context | 178 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 51 |
4545
| …and decidably tenancy-enabled | 29 |
46-
| Threading a decidably elevated context | 134 |
46+
| Threading a decidably elevated context | 135 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 94 |
4949

@@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-20 at `a454cbcc4`.
93+
Measured on 2026-10-22 at `452e9963c`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 684 |
98-
| engine-shaped types recognised | 82 |
99-
| declared objects in the registry | 118 |
97+
| tracked non-test sources scanned | 700 |
98+
| engine-shaped types recognised | 86 |
99+
| declared objects in the registry | 117 |
100100
| same-named calls subtracted as non-engine | 169 |
101101

102102
## Every site
@@ -221,6 +221,7 @@ Measured on 2026-10-20 at `a454cbcc4`.
221221
| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `RECEIPT_OBJECT` | undecidable | context, elevation undecidable | 1 |
222222
| `packages/services/service-messaging/src/messaging-service.ts` | `update` | `RECEIPT_OBJECT` | undecidable | options unreadable | 1 |
223223
| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `sys_notification` | enabled | context, elevation undecidable | 1 |
224+
| `packages/services/service-messaging/src/preference-user-conversion.ts` | `update` | `sys_notification_preference` | enabled | elevated | 1 |
224225
| `packages/services/service-messaging/src/sql-http-outbox.ts` | `insert` | `this.objectName` | undecidable | context, elevation undecidable | 1 |
225226
| `packages/services/service-messaging/src/sql-http-outbox.ts` | `update` | `this.objectName` | undecidable | context, elevation undecidable | 4 |
226227
| `packages/services/service-messaging/src/sql-http-outbox.ts` | `update` | `this.objectName` | undecidable | options unreadable | 1 |

‎packages/cli/src/commands/migrate/duplicates.contract.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ describe('#8928 os migrate duplicates — the report document', () => {
231231
organizationCounters: [{ organization: 'org_x', lastValue: 4 }],
232232
},
233233
],
234-
// One entry per index the two `kernel:ready` migrations tighten — THREE,
234+
// One entry per index the three `kernel:ready` migrations tighten — FOUR,
235235
// because the overlay migration builds one per state and either can be
236236
// blocked on its own. Present whatever the outcome: an index left out
237237
// would make "nothing blocks it" and "it was never probed" the same
@@ -253,14 +253,17 @@ describe('#8928 os migrate duplicates — the report document', () => {
253253
});
254254
});
255255

256-
it('names the three kernel:ready indexes, and the migration behind each — none for the retired sys_view_definition (ADR-0131 D13)', async () => {
256+
it('names the four kernel:ready indexes, and the migration behind each — none for the retired sys_view_definition (ADR-0131 D13)', async () => {
257257
const report = await collect();
258258
expect(
259259
report.runtimeIndexPreflight.map((p) => `${p.migration}:${p.table}:${p.index}`),
260260
).toEqual([
261261
'ensureMetadataOverlayIndexes:sys_metadata:idx_sys_metadata_overlay_active',
262262
'ensureMetadataOverlayIndexes:sys_metadata:idx_sys_metadata_overlay_draft',
263263
'ensureSysSettingIdentityIndex:sys_setting:uniq_sys_setting_organization_id_namespace_key_scope_user_id',
264+
// One admin-global default (an empty User) per (organization, topic,
265+
// channel): service-messaging's preference table, ADR-0118 D1.
266+
'ensureNotificationPreferenceIdentityIndex:sys_notification_preference:uniq_sys_notification_preference_a22d7d27',
264267
]);
265268
});
266269

‎packages/cli/src/commands/migrate/duplicates.integration.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,9 +311,9 @@ describe('#8928 os migrate duplicates — against a really booted stack', () =>
311311
// sees that something is blocked without reading every entry.
312312
expect(produced.summary.runtimeIndexesBlocked).toBe(1);
313313
expect(produced.summary.runtimeIndexBlockingRows).toBe(2);
314-
// `sys_setting` is absent on this fixture; whatever each status, the three
314+
// `sys_setting` is absent on this fixture; whatever each status, the four
315315
// indexes are all accounted for.
316-
expect(produced.runtimeIndexPreflight).toHaveLength(3);
316+
expect(produced.runtimeIndexPreflight).toHaveLength(4);
317317
expect(produced.reportVersion).toBe(2);
318318

319319
// The whole run — boot included — wrote nothing. If a future change arms a

‎packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,11 @@ const SUBJECTS: Subject[] = [
252252
control: { user_id: 'user_only_yi', topic: 'billing.invoice', channel: 'email' },
253253
trailingControl: { user_id: 'user_u1', topic: 'billing.invoice', channel: 'push' },
254254
filler: {},
255-
platformKey: { user_id: '*', topic: 'billing.invoice', channel: 'email' },
255+
// A plain user id like every other subject's platform key. It spelled
256+
// `'*'` once, the text column's admin-global sentinel; `user_id` is now a
257+
// `sys_user` reference whose EMPTY value is that default (ADR-0118 D1), so
258+
// the sentinel is no stored value any more. At this layer it is just a key.
259+
platformKey: { user_id: 'user_platform', topic: 'billing.invoice', channel: 'email' },
256260
legacyName: 'uniq_sys_notification_preference_user_id_topic_channel',
257261
// ⚠️ HASH-SUFFIXED, and this is the case the PM's A1 flagged as the one
258262
// where a single-column assumption would hide. The base name

0 commit comments

Comments
 (0)