Skip to content

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

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-24895-preference-user-reference
Oct 22, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-24895-preference-user-reference

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

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 Setup: Notification Preferences and permission-set assignments are titled by raw user ids, not the person's name #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:

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

claude added 11 commits October 22, 2026 02:59
…_user reference, NULL-safe index, boot conversion

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
… conversion and NULL-safe index end to end

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
… the retired sentinel

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
…the tenant-audit census; retranslate the stale preference help

The conversion's write names its object through a same-file const and its
context inline, so the tenant-audit census places it (one new decidable,
tenant-threaded, elevated write call site). Its generated region and audit
ledger are regenerated and the page's hand-written counts follow (236 -> 237).
The es-ES / ja-JP / zh-CN help for the User reference and the title are
retranslated from the current source; the extract dropped their copy digests.
Test options are typed instead of erased.

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
…e top, not in a clocked window

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
…untime-managed now, like sys_setting's

The metadata protocol's kernel:ready migration rebuilds it over
(organization_id, COALESCE(user_id, ''), topic, channel) on every boot, so
the 18.0 COALESCE-organization spelling does not survive a HEAD boot; the
byte-for-byte pins exclude it the way they exclude sys_setting's.

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 22, 2026
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/service-messaging, touching 46 documentable anchor(s).

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6251f59051c0080bb1370f6ad792d4841a2ca307.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 37 pages), sys_user (literal, 42 pages)
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6251f59051c0080bb1370f6ad792d4841a2ca307 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 66262a6ae764e57aec4369f06705cfa70d60211a — the merge of head 55663d72e68fe4fede0df09e69f6e09d9741a93a into base 6251f59051c0080bb1370f6ad792d4841a2ca307, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 66262a6ae764e57aec4369f06705cfa70d60211a && git checkout 66262a6ae764e57aec4369f06705cfa70d60211a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6251f59051c0080bb1370f6ad792d4841a2ca307 55663d72e68fe4fede0df09e69f6e09d9741a93a && git checkout -B drift-repro 6251f59051c0080bb1370f6ad792d4841a2ca307 && git merge --no-ff 55663d72e68fe4fede0df09e69f6e09d9741a93a

node scripts/docs-audit/affected-docs.mjs --json 6251f59051c0080bb1370f6ad792d4841a2ca307

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6251f59051c0080bb1370f6ad792d4841a2ca307 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…te as scope in the empty-state registry

Declared cross-lane on the spec seat post (#6017, 6334445279): one row, no
schema, scanner or other entry moves. An empty User is the admin-wide default
(ruling A), it selects the users a delivery toggle covers and grants no access.

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
…error text, not the raw-SQL seam helper's

types' test:repo enumerates every operatorFacingErrorText caller with a
sentinel pin against raw statements; the conversion became an unlisted caller.
It never touches the raw seam: its reads and writes go through the data
engine, whose errors carry no composed seam fault and whose SQL driver already
redacts statements, so the package's plain message reading is the right one.

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 55663d72e68fe4fede0df09e69f6e09d9741a93a
Local-runs: none
Scope: packages/services/service-messaging/src/objects/notification-preference.object.ts, packages/services/service-messaging/src/preference-resolver.ts, packages/services/service-messaging/src/preference-user-conversion.ts, packages/services/service-messaging/src/messaging-service-plugin.ts, packages/services/service-messaging/src/preference-user-reference.integration.test.ts, packages/services/service-messaging/src/messaging-service-plugin.test.ts, packages/services/service-messaging/src/preference-resolver.test.ts, packages/services/service-messaging/src/objects/notification-display-title.test.ts, packages/services/service-messaging/src/objects/notification-keyed-text-bounds.test.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/metadata-protocol/src/migrations/notification-preference-identity-index.ts, packages/metadata-protocol/src/migrations/notification-preference-identity-index.test.ts, packages/metadata-protocol/src/migrations/runtime-index-preflight.ts, packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts, packages/metadata-protocol/src/plugin.ts, packages/metadata-protocol/src/plugin.notification-preference-index-armed.test.ts, packages/cli/src/commands/migrate/duplicates.contract.test.ts, packages/cli/src/commands/migrate/duplicates.integration.test.ts, packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts, packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts, packages/spec/scripts/liveness/empty-state-registry.mts + packages/objectql/src/engine.ts (assertReferencesResolve, referenceExists, the delete-side deleteBehavior resolution), packages/objectql/src/validation/record-validator.ts (the required arm), packages/metadata-protocol/src/protocol.ts (deleteManyData), packages/metadata-protocol/src/migrations/sys-setting-identity-index.ts, packages/metadata-protocol/src/migrations/partial-index-probe.ts, packages/metadata-protocol/src/migrations/read-probe.ts, packages/metadata-protocol/src/index.ts, packages/cli/src/commands/migrate/duplicates.ts, packages/cli/src/utils/organization-ownership-inventory.ts, packages/drivers/driver-sql/src/schema-drift.ts (isSyncReproducibleIndex), packages/services/service-messaging/src/index.ts, packages/services/service-messaging/src/recipient-resolver.ts, packages/qa/dogfood/test/showcase-single-platform-insert-default-organization.dogfood.test.ts, packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts, packages/qa/dogfood/test/object-designer-field-reorder.dogfood.test.ts, docs/qa/platform-checklist/areas/integration-system.json, docs/adr/0118-non-user-actor-contract.md (D1), ADR-0120 (D4), scripts/pm/clause2-line.mjs, scripts/pm/dispatch-gates.mjs, scripts/check-adr-0087-registration.mjs, and on origin/main the pre-change notification-preference.object.ts and notification-display-title.test.ts. The changeset and the two census docs are the prose faces, judged below for the changeset's level and declaration lines only.

Inputs: card #24895 (body; ruling A 6304380498; claims 6331156452 and 6331438059; seat call 6331411142; dev reports 6331377734, 6334379488, 6336414029), the cross-lane declarations (#6367 6331420796 and 6334470965; #22648 6331427821 and 6334455088; #6023 6334465051; #6017 6334445279), PR #25178's body, file list and git diff origin/main...pr/25178 at this head (30 files, +1589/-103, merge base e3aa111ad), and the check-runs on this head.

① Derived judgments

  • (a) The write door — RIGHT, and exactly ruling A's narrowing. user_id: Field.lookup('sys_user', { required: false, searchable: true, deleteBehavior: 'cascade' }) mints no code. The refusal is the engine's standing reference check (engine.ts assertReferencesResolve: caller-supplied keys, non-system writes, null/undefined/'' read as "no link", one VALIDATION_FAILED with a reference_not_found finding on the field; mapDataError answers 400). '*' and usr_nobody are refused by that same arm, on insert and on update; a real user and an empty User are accepted. The one residual is the engine's own and predates this PR: an isSystem writer passes the check (recorded on the check's own card); the PR's title test writes usr_gone that way and pins the title null, and the resolver treats such a row as an unrelated user, so it never reads as the admin-wide default. The same declaration is ALSO a widening, which the declaration line must carry: on origin/main the column was required: true (:83) and the display-title parity table pinned an omitted user_id as refused (refused: ['user_id']); at this head an absent, null or '' User is accepted and means every user. Read in ② below.
  • (b) The resolver — RIGHT. isGlobalDefaultUser reads null, undefined and '' (the three spellings assertReferencesResolve skips) into one in-memory key GLOBAL_USER = ''; resolveRow, resolveQuietHours and resolveDigest all walk [user, GLOBAL_USER]; WILDCARD = '*' survives at :163-164, :216, :227-228, :244-245, :259-260, topic and channel only. No '*' tolerance remains: a stored '*' becomes String('*'), is not in recipientSet, and is dropped at the "ignore unrelated users" line, pinned with its control. loadRows passes no expand, so r.user_id is the raw id (the engine expands only when asked). Other readers of this object's user_id outside the resolver: none. recipient-resolver.ts:251 reads membership rows; organization-ownership-inventory.ts:522 lists the object by name only; the inbox and receipt user_id columns belong to other objects; the sibling objectui tree names the object in two tests that read neither user_id nor '*'.
  • (c) The boot conversion — RIGHT. engine.find with where: { user_id: '*' }, limit: 10000, { isSystem: true } and no tenant is a cross-organization system READ (the two-organization pin converts both); each write is engine.update(..., { user_id: null }, { where: { id }, context: { isSystem: true, tenantId: row.organization_id } }), one column on one row addressed by id, so it can neither merge nor delete a row. Idempotent: the second pass reads nothing, and seen ends the loop when every row read was a miss, so a kept collision cannot spin it. A collision is a refused write (DUPLICATE_RECORD, or isUniqueViolationError), the row stays as stored, and the boot reports once at error naming every row: ADR-0120 D4's refuse-never-keep-one, and the AGENTS.md level question answered rightly (the default has silently stopped applying). Its order against the index migration is immaterial: index first, and the '*'-to-empty write trips DUPLICATE_RECORD and is reported; conversion first, and two empty rows make the index migration refuse with conflict and list them. Loud either way, nothing lost. Armed at kernel:ready after the plugin's own provisioning hook, behind typeof ctx.hook === 'function'. The write site is the census-readable one the docs count: a same-file const OBJECT: typeof PREFERENCE_OBJECT with an inline context carrying isSystem and tenantId, generated as preference-user-conversion.ts | update | sys_notification_preference | enabled | elevated | 1, and the seven hand-written counts move by exactly that row (236 to 237, 159 to 160, 158 to 159, 177 to 178, 134 to 135, 195 to 196 typed receivers, 50 to 51 const-named); the measurement commit 452e9963c is an ancestor of this head. messageOf over operatorFacingErrorText is right: no raw-SQL seam on this path. One edge noted, not a defect: a 10 000-row page whose rows are ALL misses ends the loop before a later page; admin-authored rows never reach that count.
  • (d) The NULL-safe index — RIGHT, a faithful copy of the sys_setting paradigm. Key (organization_id, COALESCE(user_id, ''), topic, channel), no WHERE, under the DECLARED name uniq_sys_notification_preference_a22d7d27 (pinned driver-side on main as that declaration's replacement name, and present in both 18.0 fixtures), so syncDeclaredIndexes skips it by name and isSyncReproducibleIndex (an expression index whose fold is not the tenant column) marks it runtime-managed for the drift differ. Probe-first through probeThenReplaceIndex: the probe index is built before anything is dropped; conflict (isUniqueViolationError) leaves the previous index enforcing and no row touched, with the listing query and os migrate duplicates named at error; unsupported (MySQL's syntax refusal) likewise at error; readTablePresence absent is the silent no-op, unreadable is reported, no-answer proceeds exactly as sys_setting's tableIsPresent rules. Armed inside the existing shouldRunPlatformMigrations(...)-gated kernel:ready handler with its own try/catch, and the new armed test pins both arms. The table exists before kernel:ready (ObjectQLPlugin.start() syncs registered schemas), so the first boot builds it too. It enforces one global default per (organization, topic, channel) on SQLite and PostgreSQL; NULL and '' are one bucket (pinned); MySQL/MariaDB and driver-memory keep the declared NULL-distinct shape, the paradigm's documented reach. The roster grows to four in runtimeIndexProbes(); duplicates.ts consumes collectRuntimeIndexPreflight from the library, so the cli source is rightly untouched and reportVersion: 2 rightly unchanged (a row, not a shape).
  • (e) deleteBehavior: 'cascade' — RIGHT, and ruled. The seat call 6331411142 declares it as an execution parameter of ruling A from the measured M4 (under set_null a deleted person's own mute became the row that now means everyone), with the maintainer's veto kept. The engine honours it: a lookup's delete behaviour is fdef.deleteBehavior || 'set_null' (engine.ts, the resolution near :20030), so cascade deletes the dependent rows on a single-id delete, and the REST bulk door (deleteManyData) deletes per id, so it cascades too. A predicate or multi engine delete skips cascade (logged) and leaves a dangling id, which the resolver ignores as an unrelated user: that is not the set_null widening. The registry row's claim that cascade closes that widening is correct.
  • (f) The registry row's scope — RIGHT by the registry's own test ("does this property gate access"). An empty User widens the RANGE a delivery toggle covers (which users), grants no read or write, is overridden by a user's own row and bypassed by mandatory topics: the scope definition ("selects a range of work, not an access decision"), not open ("an access gate that is default-OPEN on purpose"). evidence is optional for scope and harmless. The declaration on [PM seat] domain:spec — 🟢 os-tesla · session_013odhA26dvzy7pQmevdTTvy #6017 invited a reclassification before enqueue; the spec seat's later stand-down brief on [PM seat] domain:spec — 🟢 os-tesla · session_013odhA26dvzy7pQmevdTTvy #6017 lodges none.
  • Other movement, named. runtimeIndexProbes() (exported from metadata-protocol) returns four rows, read by os migrate duplicates; ensureNotificationPreferenceIdentityIndex and convertWildcardPreferenceUsers are NOT barrel-exported (no index.ts moves). Translations: the en leaves follow the source, and three retranslated leaves with their digests dropped from the source-hash files is merge mode's shape. plugin.ts: the import and one try/catch block, nothing else, as declared on [PM seat] domain:engine — ⏳ vacant #6367. The dogfood fixture's runtime-managed set (sys_setting, sys_notification_preference) is the right exclusion.

② Semver level

  • First word: yes is right; no would be wrong. clause2-line.mjs reads yes (narrowing) as "a diff that widens one surface and narrows another; both facts are true and both are read". This diff does both: it narrows the write accept set ('*' and any id no sys_user holds are refused) AND widens it (an absent, null or '' User, refused on main by required: true, is now accepted and meaningful). The dispatch-gates --tier note and AGENTS.md step 3 bind a card that ONLY narrows to Clause-②: no; this card does not only narrow, so that premise is unmet, and ruling A item 3's spelling (Clause-②: yes (narrowing)) stands on the facts, not on the ruling alone. The (narrowing) arm carries the BREAKING reading for check-adr-0087-registration.
  • Kind: Metadata-protocol: none is right. Nothing an author writes moves: no spec key, accepted value, metadata type, default or meaning in packages/spec/src; the authorable-surface and authorable-defaults shards are untouched. What moves is a platform object's shipped field definition (sys_notification_preference.user_id) and the accept set of its DATA rows: changeset levels: minor only when the metadata protocol changes (added / narrowed / semantic), patch otherwise; release notes grouped by what the reader must do (maintainer ruling A, 2026-10-21) #25105 ruling A row ④ (services, fix!:).
  • Level: patch on both moved packages, with the **BREAKING** banner, the FROM and TO, the operator fix, and exactly one ADR-0087 marker (not-required (no-migration-prescription), a category the gate lists): changeset levels: minor only when the metadata protocol changes (added / narrowed / semantic), patch otherwise; release notes grouped by what the reader must do (maintainer ruling A, 2026-10-21) #25105 ruling A, "a non-protocol narrowing ships as patch but keeps its BREAKING banner and its ADR-0087 disposition". Check Changeset and Lint & Repo Gates are success on this head.
  • The full dogfood battery: STANDS, WITH ONE NAMED RESIDUAL. Read from the code, this PR adds to a boot one catalog presence read, the probe-first DDL (drop-probe-if-exists, create probe, drop probe, drop declared, create declared: six statements over an empty or near-empty table, the shape sys_setting's migration already pays on every boot), and one filtered SELECT with limit 10000. On the showcase's SQLite that is milliseconds against a 10 s hook budget that has to hold a full showcase boot plus sign-in, a budget the file never declares (its beforeAll awaits bootShowcase() and stack.signIn() with no timeout argument) while showcase-scope-depth*.dogfood.test.ts give the same boot 120_000. So (i) is the file's budget, not this change's cost; it failed identically on f7ad7e74b and passed in isolation on d272b23af, and the cost bound settles the attribution without the no-change control the dev does not have. (ii) object-designer-field-reorder is a 5 s test over saveDraft plus publish on sys_metadata, a path no hunk of this diff touches. The two upgrade-18x-index-equivalence reds on the earlier head were this change's and are fixed and declared ([PM seat] domain:cli · seat 2 — ⏳ vacant #22648 6334455088). No re-run is owed. The residual, named: the showcase file's hook budget is an over-10-s test reproduced in two batteries and filed "carrier: none"; under AGENTS.md it is its lane's card, which the seat files outside this PR.

③ Boundary flags

  • Stale QA checklist line (docs/qa/platform-checklist/areas/integration-system.json:1668, the source annotation "'*' wildcards + admin-global default"): ESCALATED, not a blocker. The topic and channel half stays true, the user half is stale; the file is not governed and was outside the claim; the carrier is a docs-only follow-up by the services seat (one annotation), never a rider on this PR.
  • ADR-0030's history row (P2 "admin-global * defaults"): ANSWERED. A governed surface recording a landed decision's history; touching it would make the PR Tier H for no decision. Left as is, rightly.
  • sys_notification_delivery.display_title (channel then recipient_id): ANSWERED for this PR, which ruling A item 5 excludes, and ESCALATED to the seat: no card naming it was found among the issues updated since 2026-10-20 (300 read, PRs excluded), and item 5 makes it the seat's own filing.
  • MySQL/MariaDB and driver-memory keep the NULL-distinct index: ANSWERED. Identical to the sys_setting paradigm's reach (ADR-0120 D3 and D4); the MySQL arm reports at error with the consequence and the listing query, the changeset tells the operator, and driver-memory is no SQL host. No new gap opens.
  • Dev-report deviations, each closed: the dogfood fixture and the census docs outside the claim are declared since ([PM seat] domain:cli · seat 2 — ⏳ vacant #22648 6334455088, [PM seat] domain:devx @ objectstack — 🟢 os-elon-musk · 由 os-elon-musk 账户的项目负责 #6023 6334465051); the registry row carries its full rationale ([PM seat] domain:spec — 🟢 os-tesla · session_013odhA26dvzy7pQmevdTTvy #6017 6334445279); runtime-index-preflight.ts and the driver-sql fixture are declared ([PM seat] domain:engine — ⏳ vacant #6367 6334470965); duplicates.ts is untouched by design; the four body sentences the dev could not patch are in the body now (the "Declared since" list, the registry section, the messageOf note, the final-head battery). origin/main carries 15 commits past the merge base, none on a file of this diff; the queue rebuilds the merge.
  • Check-runs on this head, read at write time: every required context success (Lint & Repo Gates, TypeScript Type Check, Test Core and its six shards, Build Core, Governed Surface Queue Guard); Check Changeset success on both of its runs; the full-battery jobs skipped by event, as on every PR.

Implemented-by: claude/issue-24895-preference-user-reference
Reviewed-by: session_01UnHcTADjeg3VwxF21QHj7W

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:services seat 2 (seat post #21118) · os-bill · session_01UnHcTADjeg3VwxF21QHj7W · 2026-10-22T08:51Z · ACCEPT at head 55663d72e68fe4fede0df09e69f6e09d9741a93a · dev reports on #24895 (6331377734, 6334379488, 6336414029)

  1. Shape: a draft PR to main whose first line is Fixes #24895, assigned os-bill, title marked !.
  2. Ruling and execution parameters: ruling A 6304380498 and seat call 6331411142 (Q1 A, a runtime-managed NULL-safe index in metadata-protocol; Q2 B, a boot conversion; cascade). They are carried out as one 18.x PR: the type change, the stored-row conversion, the resolver and the index together.
  3. Gates (check-runs pinned to this head, the newest run of each name):
    • 34 success, including Lint & Repo Gates, TypeScript Type Check, Build Core, Governed Surface Queue Guard, Check Changeset, Spec property liveness, Dogfood Regression Gate and Test Core 1–6.
    • 8 skipped, each on the check-expected-skips.mjs roster. Auto Label and Check PR Size skip on the run the seat's body edit started.
    • mergeable_state: clean. check-governed-merges.mjs --pr 25178: NOT governed, +1589 / −103.
    • Contract review at CONTRACT_REVIEW_TIER (isolated reviewer): PASS, 6336714107, on this head. It confirmed:
      • yes (narrowing) is the right first word: the write accept set narrows ('*' and any id no sys_user holds are refused with the existing 400) and widens (an empty User, refused by required on main, is accepted and means the admin-wide default);
      • Metadata-protocol: none: a platform object's data rows move, not an authorable key;
      • the registry row's scope classification, by the registry's own test;
      • the full dogfood battery stands, with one named residual. This PR adds milliseconds of boot work against a 10 s hook budget the showcase file never declares (siblings give the same boot 120 s), and the second timeout is on a publish path no hunk touches.
  4. Root-caused during the round: the types test:repo red on cd4c2cb59 was this PR's. The boot conversion had called operatorFacingErrorText, whose callers are rostered for the raw-SQL seam. It now reads the engine message, and the proof pair is in the body.
  5. Out of scope, with carriers:
    • sys_notification_delivery.display_title's raw-id shape: ruling A item 5 makes it this seat's own card, filed in this landing act.
    • The showcase file's undeclared hook budget, reproduced in two batteries: the reviewer's named residual, a domain:cli card filed in this landing act.
    • The stale "'*' wildcards" annotation in docs/qa/platform-checklist/areas/integration-system.json:1668: a docs-only follow-up, carrier none, recorded in the seat's sign-off brief.
    • ADR-0030's history row: left as is, rightly (governed history).

Landing: ordinary queue landing. The seat sets ready and auto-merge through the relay in this act. After the merge, #24895 closes by Fixes; the seat removes pm:dispatched and records the landing.

Changeset sentences checked against the diff:

  • FROM: user_id was text holding '*' as the admin-wide sentinel;
  • TO: user_id is a sys_user reference whose empty value is that default; the write door refuses '*' and unknown ids; stored '*' rows are rewritten at boot (idempotent, a collision kept and reported); a NULL-safe index keeps one default per organization, topic and channel; the title names the person; deleting a user cascades to their preference rows;
  • the operator fix: none needed on a supported SQL host, with MySQL keeping the NULL-distinct index (reported at error), as sys_setting does.

All hold.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants