Repository navigation
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
Conversation
…_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>
Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…module top Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…ence help Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…eference-user-reference
…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>
…eference-user-reference
…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>
📓 Docs Drift CheckThis PR changes 2 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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>
Contract reviewServed-tier: Inputs: card #24895 (body; ruling A ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对
Landing: ordinary queue landing. The seat sets ready and auto-merge through the relay in this act. After the merge, #24895 closes by Changeset sentences checked against the diff:
All hold. Generated by Claude Code |
Fixes #24895
Clause-②: yes (narrowing)
Metadata-protocol: none
sys_notification_preference.user_idbecomes asys_userreference. 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 in6331411142: Q1 A (a runtime-managed NULL-safe index inmetadata-protocol), Q2 B (a boot conversion inservice-messaging), andcascade. The file surface is the amended claim6331438059; 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 atCONTRACT_REVIEW_TIERis owed before the queue.What changes
service-messagingnotification-preference.object.ts:user_idisField.lookup('sys_user', { required: false, searchable: true, deleteBehavior: 'cascade' }), with nomaxLength; a reference's width is the parent id column's.cascadeis load-bearing. Under the lookup defaultset_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.(isBlank(trim(record.user_id.?name.orValue(''))) ? record.user_id.?email.orValue('All users') : record.user_id.?name.orValue('')) + ' · ' + topic + ' · ' + channel.All users.All users, which would name the wrong row.user_idis read only through the hop (traversal-bare-and-traversed).titleFormatis unchanged.preference-resolver.ts: the admin-global default is the row whoseuser_idis empty.null, absent and''all count, the three spellings the engine's own reference check reads as "no link".'*'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 atkernel:ready, after table provisioning. It rewrites every storeduser_id = '*'to an empty User, across organizations.'*'row whose (organization, topic, channel) already has an empty-User default is kept as stored and reported once aterror, naming the row (ADR-0120 D4: refuse, never keep-one). A missing table is a silentabsent.enbundle is regenerated. Theuser_idand title help are retranslated ines-ES,ja-JPandzh-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 thesys_settingparadigm.uniq_sys_notification_preference_a22d7d27, as(organization_id, COALESCE(user_id, ''), topic, channel).partial-index-probe.ts, over thedriver-exec.tsseam.errorline ships the listing query.unsupported, reported aterror).plugin.ts: the migration is armed inside the existingrunPlatformMigrations-gatedkernel:readyhandler, with its own try/catch. That is the only change in the file.migrations/runtime-index-preflight.ts: the index's probe joins the rosteros migrate duplicatesreports.dogfood:upgrade-18x-index-equivalence.dogfood.test.tstreatssys_notification_preference's organization unique as runtime-managed, the way it treatssys_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 (zerorecreate_index, zero destructive drift) are unchanged and green.cli(domain:cli, declared on #22648): no source change. The probe lives inmetadata-protocol's preflight roster, whichduplicates.tsalready consumes. Only the two roster pins move from three indexes to four.driver-sql: the one pin that spelled'*'is flipped, toplatformKey: { 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 storeduser_id, which this PR retires.Premises, measured
origin/main23540582f: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.SysUser):(organization_id, user_id, topic, channel);409 DUPLICATE_RECORD.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.varchar(255) nullableboth as text and as a lookup, so the retype needs no DDL.requiredemits no NOT NULL.Pins (all on a real engine unless noted)
src/preference-user-reference.integration.test.ts(new, 13 tests):sys_userlookup,cascade, no width.'*'andusr_nobody→code: 'VALIDATION_FAILED',mapDataError(...).status === 400, a finding onuser_idwith codereference_not_found. An update moving a row to'*'is refused the same way. A real user and an empty User are accepted.Bea Person · deal.won · inbox;All users · deal.won · inbox;All users · * · email(topic default);nullfor an id nobody holds.'*'rows → empty, a real user's row untouched,{status:'converted', converted:2};{status:'nothing-to-convert'}and the rows are byte-identical;absent;no-engine.assembleMetadataProtocolkernel:readyhandlers run, it is refusedDUPLICATE_RECORD/409;'*'row that collides is kept,reason: 'duplicate', and logged once aterror.sys_setting. No test claims otherwise.'*'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 onkernel: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):''and NULL in one bucket;errortext and the listing query;unsupported, and an unclassified failure.metadata-protocol/src/plugin.notification-preference-index-armed.test.ts(new): armed underrunPlatformMigrations, and absent without it.runtime-index-preflight.test.ts: the preference probe reportsblockedwith the folded key, and the roster and statement-identity pins grow.Pins reversed (not deleted)
preference-resolver.test.ts:'*';'*'is not, with its control.objects/notification-display-title.test.ts: the preference case (raw-id titleusr_alice · billing.invoice · email,user_idrequired) leaves the titleFormat-parity table. It is reversed in the new integration file: a person title, and an empty User accepted and titledAll users. Delivery and receipt keep every assertion.objects/notification-keyed-text-bounds.test.ts:user_idwas bounded to 255. It is now a lookup with nomaxLength.driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts:'*'→'user_platform'(above).cli/src/commands/migrate/duplicates.contract.test.tsand.integration.test.ts: the roster is four indexes.Ablations (one-time; committed fix first;
node scripts/ablation-replace.mjswrap mode; every leg restored toblob == HEAD,git diff HEADempty)isGlobalDefaultUser→return false;preference-resolver.test.ts×3, resolver end-to-end ×1)LEGACY_WILDCARD_USERinstead ofnulldeleteBehavior: 'cascade'deletedensureNotificationPreferenceIdentityIndexcall inplugin.tsneutralised409refusal, collision), arming pin 1 failed of 2Outside 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-censusholdscontent/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.mdto the tree.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.consttypedtypeof PREFERENCE_OBJECT, and an inline context carryingisSystemand the row'stenantId. No undecidable count moves.Declared since, by the seat:
6334445279;6334455088;6334465051;runtime-index-preflight.tsand thedriver-sqlfixture: [PM seat] domain:engine — ⏳ vacant #63676334470965.Acceptance notes
All usersliteral 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:1668still 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_titlehas the same shape (channel → recipient_id). Per ruling item 5 it is the seat's separate card, not this PR.unsupportedis reported aterroron MySQL), exactly as forsys_setting.The empty-state registry row (declared on #6017
6334445279)packages/spec/scripts/liveness/empty-state-registry.mtscarries one row:user_id, and the semantics arescope;6304380498) and grants no data access; a user's own row overrides it, mandatory topics bypass it, andcascadecloses the set_null widening;preference-resolver.ts'sresolveRow.check:empty-stateexits 0 with the row and 1 without it (UNCLASSIFIED … notification-preference.object.ts:110 — user_id), and the file was restored toblob == HEAD. Nothing else inpackages/specmoves.The conversion's error text (
55663d72e)The boot conversion reports the engine error's message (a local
messageOf), notoperatorFacingErrorText. Thetypestest:reporoster 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
d272b23afunless 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 at80ad1c40d; nometadata-protocolfile 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 --noEmitclean, andcheck:test-typecheck: OK — @objectstack/cli's test layer compiles.!title and the(narrowing)arm), the CI command under the lock: the closure build, thenpnpm --filter @objectstack/dogfood test.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: thebeforeAllhook times out atbootShowcase(), 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 thef7ad7e74bbattery 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.f7ad7e74brun's twoupgrade-18x-index-equivalencefailures 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/objectstackon this diff → 103 commands. Each was run with its exit code captured before any pipe.check:type-check-debthit 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.--ranreconciliation:103 derived, 103 run, 0 NOT-MEASURED, 0 UNRUN.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).Generated by Claude Code