You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
service-messaging: sys_notification_preference.user_id is a text column holding a user id or the sentinel '*', so the preference's title can only show the raw id; naming the person means re-typing it as a user reference (an accept-set narrowing) #24895
① class a — measured from the HotCRM Setup pass (F-25). This is the half of #24775 that the dev could not land without a contract change.
Filed by the domain:services PM seat, seat 1 (seat post #6021, session_011TdZ8GjixNZFLz1xJD9zzK), from the os-dev report on #24775 (open question 1). ⛔ Unlabelled and ungraded: grading, routing and the timing (「是否提前」) of a narrowing belong to triage and the maintainer. Ruled: A, one PR into 18.x (6304380498).
Reader, and what they do. Triage grades this card. A breaking accept-set narrowing with a stored-row conversion is protocol class, so the maintainer rules on it and on its timing.
The finding
Setup → Configuration → Notification Preferences titles each row by the raw user id, for example ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap · urgent_task · inbox.
Why:
sys_notification_preference.user_id is Field.text. It holds a sys_user.id, or the literal '*', which is the admin-global default preference-resolver.ts reads as WILDCARD.
The formula read path serves a hop only through reference value types (lookup, master_detail, user, tree). A text-column hop was measured to read null on the real engine.
So the title cannot name the person while the column is text.
The same shape covers two more titles:
sys_notification_delivery.display_title is channel → recipient_id (notification-delivery.object.ts:95);
sys_notification_subscription.display_title is principal · topic (notification-subscription.object.ts:81).
Business need: the Setup grid is the measured producer. git grep finds zero code producers of '*' rows; only the resolver constant and tests use it.
Contract: a sentinel is removed from a user column, and the write door refuses a non-user id that today is stored and silently matches nobody.
Cost: Clause-②: yes (narrowing) and BREAKING. It owes a stored-row conversion ('*' → null), the resolver change, and a NULL-safe user part in the (user_id, topic, channel) unique index.
Effort M.
B. A console display hint on user_id. No shipped widget names a user id or '*', and a hint cannot reach the composite formula title. Does not close the finding.
C. A stored denormalised name column. Goes stale on rename.
D. A formula-language by-id lookup over a text column. A second traversal dialect; the spec and formula lane's.
The dev recommends A, as this card. The long-term and AI-safety axes both favour typing the column, and the need is measured. The cost is a breaking narrowing, which is the maintainer's to rule and to time.
Dedupe: query run by this seat, hit count including closed
Ruling: batch #380 item 2 · letter A · maintainer 「#380 同意」 2026-10-20T23:47Z
Director seat, summon #45 continuation 2, session_01Lg6GQ4xjqmvEzYv78d6H7i. Presented in this session's chat as batch #380 item 2, from the decision 6302691562 (domain:services seat 2); the director's chat delta re-checked on origin/maine4d604efd39f that user_id is still Field.text (notification-preference.object.ts:83), the resolver's WILDCARD = '*' (preference-resolver.ts:69), zero code producers of '*' rows, and ADR-0118 D1's text (「用户 lookup 列中,系统 actor = null;不造魔法账号,不存哨兵」). The seat recommended A in two steps under 「甲」; the director presented A as ONE step into 18.x under the maintainer's word of this session (「暂时不考虑 v19,协议的调整原则上也是尽快,不考虑退役版本。」, recorded on #24910 as 6303859748 and under 「甲」 on #6015 as 6303865025). The maintainer's 「#380 同意」 takes letter A, one PR, 18.x. Thread read through 6302691562 at this write; nothing newer.
Governing text: ADR-0118 D1 (no sentinel in a user column; a non-user is null); ADR-0079 (the title follows the nameField pointer); 「甲」 as amended (6303865025): a narrowing ships now, with its stored-row conversion, in an 18.x minor.
What the ruling binds
A — sys_notification_preference.user_id becomes a user reference. The admin-global default is an empty reference instead of the sentinel '*' (ADR-0118 D1's representation); the resolver reads the empty reference as the wildcard; the (user_id, topic, channel) unique index is NULL-safe; the title reads the person one hop (the route PR fix(plugin-security): a permission-set assignment is titled by the person's name, not the raw user id #24885 uses).
The write door refuses a non-user id that today is stored and silently matches nobody. BREAKING, declared Clause-②: yes (narrowing) in the changeset, with the ADR-0087 marker the conversion's shape calls for; contract review at CONTRACT_REVIEW_TIER.
B, C and D are not taken (B cannot reach the composite title; C goes stale on rename; D is a second traversal dialect).
Claim: PM loop round 1 · 2026-10-22T02:22Z · p3, domain:services, ruling A (6304380498, the maintainer's 「#380 同意」): one PR, 18.x
Session: session_01UnHcTADjeg3VwxF21QHj7W (the dispatching seat; the dev is a mode:subagent run of this session, told apart by its branch)
Account: os-bill (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-24895-preference-user-reference
Worktree: objectstack-issue-24895
Domain: domain:services
Seat: domain:services#2 (seat post #21118)
Ruling-ref: 6304380498
File surface (at origin/main23540582f):
packages/services/service-messaging/src/objects/notification-preference.object.ts: user_id (about :83), the title, and the (user_id, topic, channel) unique index (about :165).
packages/services/service-messaging/src/preference-resolver.ts: WILDCARD (about :69) and its readers.
The stored-row conversion ('*' → empty), in the mechanism the PR fix(metadata)!: sys_metadata_history.recorded_by 存 NULL 而非哨兵字符串 'system' (#4556) #4742 precedent used, if it lives in service-messaging. If the conversion or a NULL-safe unique index needs a file outside service-messaging (a driver, objectql, platform-objects), stop and report, so the seat declares the cross-lane edit first.
Stop on a breach and explain it in the report.
Container & model: M, mode:subagent, model: opus (TIER_DEFAULT; dispatch-gates --tier on this surface: "no path-derived mandate … floor sonnet · default opus · ceiling fable")
Clause-②: yes (narrowing) — as the ruling declares: the write door refuses a non-user id (and '*') that it stores today. A review at CONTRACT_REVIEW_TIER is owed before the queue.
Responsibility: service-messaging's sys_notification_preference stores a user id or the sentinel '*' in a text column, so its title cannot name the person (ADR-0118 D1 forbids sentinels in a user column) | a user reference with an empty value for the non-user default (ADR-0118 D1), as PR #4742 did for 'system' | every administrator reading Setup → Notification Preferences; measured in HotCRM's Setup pass (F-25)
Thread-read: 6304380498
Serial constraints cleared:
No open PR touches service-messaging (the open PRs' file lists were read this round).
The last changes to these files on main are 8f4260fe6 and earlier; none in flight.
os-dev-report
{
"issue": 24895,
"status": "blocked",
"branch": "claude/issue-24895-preference-user-reference",
"pr": null,
"session": "session_01UnHcTADjeg3VwxF21QHj7W — mode:subagent run of the dispatching seat (the parent's id, per the Claude-Session line)",
"premise_still_valid": true,
"summary": "Stopped before the first edit under Partition 2.3: two of the ruling's four parts cannot land inside packages/services/service-messaging, so the seat has to declare a cross-lane surface first. (1) The NULL-safe (user_id, topic, channel) unique index: measured on driver-sql (better-sqlite3), the declared index materializes as CREATE UNIQUE INDEX uniq_sys_notification_preference_a22d7d27 ON sys_notification_preference (organization_id, user_id, topic, channel). It has no NULL-safe part on user_id, and two empty-reference rows for the same (organization, topic, channel) were both accepted. IndexSchema has only name/fields/unique, and driver-sql derives its NULL-safe set from the tenant column alone, so a declaration cannot express this index. The repo's existing route is a runtime-managed kernel:ready index migration in packages/metadata-protocol/src/migrations (sys-setting-identity-index.ts with partial-index-probe.ts and driver-exec.ts), armed in packages/metadata-protocol/src/plugin.ts assembleMetadataProtocol, with its duplicate pre-flight in packages/cli/src/commands/migrate/duplicates.ts (ADR-0120 D4 amendment). The alternative is ADR-0120's parked declared-vocabulary route (packages/spec plus the drivers). (2) The stored-row conversion: PR #4742 put its ADR-0119 D2 journal plan in the owning package, which would fit here, but its operator entry is packages/cli/src/commands/migrate/recorded-by.ts, composed through packages/cli/src/utils/data-migration-plugins.ts. Those are cli files. Everything else was measured feasible in lane; see tests M0-M4 and the two open questions.",
"tests": "No diff, so no package suite was run. Measurements, at origin/main 2354058 in worktree objectstack-issue-24895: (a) build of the dependency closure: OS_VERIFY_LOCK_SLOT=issue-24895-dev bash scripts/pm/os-verify-lock.sh -c \"pnpm --workspace-concurrency=2 --filter '@objectstack/service-messaging^...' build\" → VERDICT command-exit 0 · held the lock 195s. (b) Scratch real-engine probe (ObjectQL + SqlDriver better-sqlite3 + platform-objects SysUser; a variant of the shipped object with user_id: Field.lookup('sys_user', { required: false })). It was never committed and has been deleted (git status clean). Command: pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2 src/zz-scratch-24895.test.ts under the lock → Test Files 1 passed (1) · Tests 5 passed (5), VERDICT command-exit 0, run twice. Readings: M0: the user_id column is varchar(255) nullable both as text (main) and as lookup, so the retype needs no DDL; the registered lookup carries no deleteBehavior, so the spec default set_null applies. M1: empty-reference row #1 ACCEPTED, empty-reference row #2 for the same (org, topic, channel) ACCEPTED; control: the same real user twice → REFUSED code=DUPLICATE_RECORD status=409. M2: the lookup hop titles a row Bea Person · t.title · inbox. The empty reference titles it All users · t.title · inbox through the optional read record.user_id.?name.orValue('All users') (with the ?email fallback). A dangling user id written by system reads null. The text-column hop control reads null, which confirms the #24775 measurement for Partition 2.2. M3: non-system writes to the lookup: '*' → REFUSED code=VALIDATION_FAILED status=400 fields=[[user_id, reference_not_found]] msg=User: no sys_user record has id \"*\"; usr_nobody → the same refusal; a real user → ACCEPTED; an omitted user_id → ACCEPTED. Control on main's text column: '*' ACCEPTED, usr_nobody ACCEPTED. M4: deleting a sys_user under the default set_null → row after: {user_id: null, enabled: false}. So the person's own mute became an empty-reference row, which is the admin-global default; the retype has to declare deleteBehavior: 'cascade'. (c) Premise greps: git grep -n \"user_id: Field.text\" origin/main -- …notification-preference.object.ts → 1 hit (:83). git grep -n \"const WILDCARD = '\\*'\" origin/main -- …preference-resolver.ts → 1 hit (:69). git grep -n -E \"user_id: *'\\*'\" origin/main -- packages examples ':!*.test.ts' → exit 1, 0 hits. Positive control without the test exclusion → 3 hits: preference-resolver.test.ts ×2 and driver-sql sql-driver-tenant-scoped-declared-unique.test.ts ×1. No ablation: no diff, so no tested behaviour changed.",
"gates": "NOT MEASURED: every family. Reason: no diff. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → exit 2: 'this branch changes nothing against origin/main (merge base 2354058) — nothing to derive'. No PR, so there are no check-runs to cite.",
"dogfood_battery": "n/a — no PR and no diff (stopped before the first edit)",
"mcp_calls": "0 — none",
"api_writes": "1 — this os-dev-report comment on #24895 through scripts/pm/post-stamped.mjs (issue-comment create). The empty-branch git push is not a REST write.",
"files_changed": [],
"line_budget": "n/a — no diff",
"deviations": [
"A scratch measurement test was written to packages/services/service-messaging/src/zz-scratch-24895.test.ts in the worktree, run twice under the verify lock, and deleted. It was never committed or pushed; git status is clean.",
"The worktree was removed after this report. The remote branch holds only the empty-branch marker at 2354058."
],
"open_questions": [
{
"question": "Q1. Where does the ruled NULL-safe (user_id, topic, channel) unique index land? A declaration cannot express it (M0/M1).",
"options": [
"A — A fourth runtime-managed kernel:ready index migration in packages/metadata-protocol/src/migrations, following the #8629 sys_setting paradigm. It reuses the declared name uniq_sys_notification_preference_a22d7d27, keys (organization_id, COALESCE(user_id, ''), topic, channel), builds probe-first and refuses on duplicates, and is armed from assembleMetadataProtocol under runPlatformMigrations. Its duplicate probe goes into os migrate duplicates (packages/cli). Cross-lane: metadata-protocol + cli. Effort S-M. SQL drivers only: driver-memory stays NULL-distinct on user_id, as it already does for sys_setting.",
"B — ADR-0120's parked long-term route: a declared NULL-safe column list on IndexSchema (packages/spec), materialized by driver-sql syncDeclaredIndexes and schema-drift and by driver-memory. It also retires the three existing runtime migrations. Spec and driver lanes, Metadata-protocol: added, ADR-class. Effort L.",
"C — service-messaging carries its own copy of the runtime-index paradigm, importing metadata-protocol's exported helpers as a new runtime dependency. It stays within the package's files, but it forks a paradigm that ADR-0120 D4 counts as three metadata-protocol migrations, and os migrate duplicates still needs the probe (cli)."
],
"recommendation": "A. Business need: measured on this object; M1 shows two competing global defaults accepted today, and the global bucket is exactly what the ruling wants constrained. Long-term: it is the route #8629's ruling chose for now, with B parked as the long-term form, so A adds a fourth instance that B later retires together with the other three rather than a new dialect. AI safety: a duplicate global default is refused loudly at the database, and the declaration keeps its fallback shape. Startup focus: no new authorable key and no new gate; B is a capability expansion with no second consumer measured."
},
{
"question": "Q2. How does the stored '' → empty conversion run? PR #4742's operator entry lives in packages/cli.",
"options": [
"A — The PR #4742 shape: an ADR-0119 D2 journal plan in service-messaging, plus os migrate with a new subcommand in packages/cli (dry run by default, --apply), the plan registered at kernel:ready by the messaging plugin for os migrate resume, and data-migration-plugins.ts composing service-messaging. Cross-lane: cli.",
"B — A boot-time conversion armed by service-messaging's own plugin at kernel:ready: an idempotent update setting user_id to empty where it holds '', in system context across organizations. In lane, on the backfillSeedTenancy boot-repair precedent.",
"C — Both: B at boot, with A as the inspect and re-run door."
],
"recommendation": "B, measured reason first. PR #4742's 'system' sentinel only affected display. A stored '' row changes behaviour: once the resolver reads only the empty reference, every unconverted '' row stops applying, so admin-global mutes stop silently until an operator runs A. Converted rows cannot trip the tightened index, because '' rows are already unique per (organization, topic, channel) under today's index. Business need: zero code producers and few admin-authored rows, so the rewrite is tiny. Long-term: it closes the behaviour window with no operator step and without any '' tolerance left in the resolver (contract-first, matching the ruling's 'no warning-only half'). AI safety: there is no step to forget. Startup focus: no new CLI command surface. With B only Q1 stays cross-lane. If the seat reads the ruling's 'PR #4742 shape' as fixing the mechanism, choose C rather than A alone."
}
],
"out_of_scope_findings": [
"carrier: #24895 (this card's next round) · packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts:255 spells platformKey: { user_id: '*' } as a raw driver-level key, with no reference check at that layer. It keeps passing after the retype because the declared index is unchanged. It is the one cross-lane pin that still spells the retired sentinel: flip it under the cross-lane declaration, or keep it as an arbitrary key value. Noted, not filed.",
"carrier: #24895 · in-lane pins to reverse when the card proceeds: preference-resolver.test.ts (two user_id: '*' rows); notification-display-title.test.ts and notification-preference.organization-unique.test.ts (the title and index declarations); the object's docblock and the field description that name '*'. Noted, not filed."
]
}
Seat call on the dev report (blocked before any edit): the ruling's execution parameters · domain:services seat 2 (seat post #21118) · os-bill · session_01UnHcTADjeg3VwxF21QHj7W · 2026-10-22T02:43Z · Ruling-ref: 6304380498
The dev measured on a real engine (scratch only), and stopped where the dispatch said to.
The ruled type change, resolver and title land in service-messaging. '*' and a non-user id are refused with reference_not_found.
Two parts cannot land there:
the NULL-safe unique index: a declaration cannot express it, and two empty-reference rows for the same organization, topic and channel are both accepted today;
These are execution parameters of ruling A (6304380498), settled here. The maintainer keeps a veto.
Q1, the NULL-safe index → A: a fourth runtime-managed index migration in packages/metadata-protocol/src/migrations/.
It follows the sys_setting identity-index paradigm (sys-setting-identity-index.ts, partial-index-probe.ts, driver-exec.ts), armed from assembleMetadataProtocol under runPlatformMigrations. Its duplicate probe goes into os migrate duplicates (packages/cli/src/commands/migrate/duplicates.ts).
That is the route ADR-0120 D4 (as amended) runs today. A fourth instance is retired later with the other three by the parked declared-vocabulary route.
Not B: a NULL-safe column list on IndexSchema is a new authorable key in packages/spec, ADR-class, and the spec lane's and the maintainer's.
Not C: a copy of the paradigm inside service-messaging.
The measured reason: once the resolver reads only the empty reference, every unconverted '*' row silently stops applying. A boot conversion leaves no such window, and leaves no '*' tolerance in the resolver.
The delete behavior → cascade, declared on the new reference. Measured (M4): under the default set_null, deleting a user turns that person's own preference into an empty-reference row, which this ruling makes the admin-global default. A person's mute must never become everyone's.
Claim: PM loop round 1 · 2026-10-22T02:45Z · amends claim 6331156452: the file surface widens to the seat call 6331411142 (Q1 A, Q2 B, cascade)
Session: session_01UnHcTADjeg3VwxF21QHj7W (the dispatching seat; the dev is a mode:subagent run of this session, told apart by its branch)
Account: os-bill (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-24895-preference-user-reference
Worktree: objectstack-issue-24895
Domain: domain:services
Seat: domain:services#2 (seat post #21118)
Ruling-ref: 6304380498
File surface (at origin/main23540582f): everything in claim 6331156452, plus:
packages/services/service-messaging/src/: the plugin's kernel:ready boot conversion ('*' → empty, idempotent, system context across organizations), and deleteBehavior: 'cascade' on the new reference.
packages/metadata-protocol/src/migrations/: one new runtime-managed index migration for the NULL-safe (organization_id, user_id, topic, channel) index, reusing partial-index-probe.ts and driver-exec.ts, with its tests. It is armed in packages/metadata-protocol/src/plugin.ts (assembleMetadataProtocol, under runPlatformMigrations) and nothing else in that file. domain:engine's; declared on [PM seat] domain:engine — ⏳ vacant #6367 (6331420796).
packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts: only the one pin that spells '*' as a raw key, if the dev flips it (domain:engine, covered by the same declaration).
⛔ Not packages/spec/** (Q1 B not taken), not a new os migrate subcommand (Q2 A not taken unless the maintainer reads the ruling so), not sys_notification_delivery.
Stop on a breach and explain it in the report.
Container & model: M, mode:subagent, model: opus (TIER_DEFAULT; dispatch-gates --tier on this surface: "no path-derived mandate … floor sonnet · default opus · ceiling fable")
Clause-②: yes (narrowing) — unchanged from claim 6331156452. A review at CONTRACT_REVIEW_TIER is owed before the queue.
Responsibility: unchanged from claim 6331156452
Thread-read: 6331411142
Serial constraints cleared: no open PR touches service-messaging, metadata-protocol's plugin.ts or migrations/, or packages/cli/src/commands/migrate/duplicates.ts (every open PR's file list was read this round).
os-dev-report
{
"issue": 24895,
"status": "blocked",
"branch": "claude/issue-24895-preference-user-reference",
"pr": "#25178",
"session": "session_01UnHcTADjeg3VwxF21QHj7W — mode:subagent run of the dispatching seat (the parent's id, per the Claude-Session line)",
"premise_still_valid": true,
"summary": "Implementation round done as ruling A plus seat call 6331411142 order it; draft PR #25178 is open at head d272b23, assigned to os-bill, with Fixes #24895 first and Clause-②: yes (narrowing) at line start. What it does:\n- user_id is an optional sys_user lookup with deleteBehavior cascade, and an empty User is the admin-global default.\n- The title reads the person one hop through the optional read: name, else email; an empty User reads All users · topic · channel; an unresolvable id reads null.\n- The write door refuses '' and non-user ids: 400 VALIDATION_FAILED, reference_not_found.\n- The resolver reads null, absent and '' as the global default, and carries no '' tolerance.\n- service-messaging's plugin rewrites stored '' to empty at kernel:ready. It is idempotent and works across organizations; a collision is kept and reported at error.\n- metadata-protocol adds the fourth runtime-managed NULL-safe index, (organization_id, COALESCE(user_id, ''), topic, channel) under the declared name, armed under runPlatformMigrations, with its probe in the os migrate duplicates preflight roster.\nBLOCKED on ONE item only the seat can clear: pnpm --filter @objectstack/spec run check:empty-state exits 1. user_id's permissive empty state (an empty User means every user) must be classified in packages/spec/scripts/liveness/empty-state-registry.mts. The claim puts packages/spec/** out of bounds 'without that stop', so I did not edit it and did not reword the comment to slip past the scanner (the registry's own precedent: 'registered rather than reworded'). The row it needs:\n- file: packages/services/service-messaging/src/objects/notification-preference.object.ts\n- property: user_id\n- semantics: scope\n- rationale: the empty User selects which users a delivery toggle covers. It grants no data access, a user's own row overrides it, and mandatory topics bypass it. The set_null widening hazard is closed by cascade.\n- evidence: packages/services/service-messaging/src/preference-resolver.ts (resolveRow)\nEverything else is green locally.",
"tests": "Real outputs; the worktree was at d272b23 unless a line says otherwise.\n- service-messaging: pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2 → Test Files 56 passed (56), Tests 621 passed (621). run typecheck → clean.\n- metadata-protocol: exec vitest run --maxWorkers=2 → Test Files 254 passed | 3 skipped (257), Tests 20857 passed | 19 skipped. Run at 80ad1c4; no metadata-protocol file changed after it. run typecheck → clean.\n- cli: exec vitest run --maxWorkers=2 src/commands/migrate/duplicates (both tiers) → Test Files 6 passed (6), Tests 37 passed (37). run typecheck → clean, plus check:test-typecheck: OK.\n- driver-sql: the pin file → 85 passed. run typecheck → clean.\nAblations: committed fix first, node scripts/ablation-replace.mjs wrap mode, every leg proved restored ('blob == HEAD and git diff HEAD is empty'); no dist step, because every subject resolves to src (relative imports, plus the vitest alias for metadata-protocol).\n- A1 resolver (isGlobalDefaultUser → return false): 4 failed of 40.\n- A2 conversion (update writes '' back): 4 failed of 37.\n- A3 cascade (deleteBehavior deleted): 2 failed of 13.\n- A4 arming (the plugin.ts ensure call neutralised): integration 2 failed of 13, arming pin 1 failed of 2.\nPremises measured at origin/main 2354058:\n- The declared index admits two empty-User rows; control: the same user twice → 409 DUPLICATE_RECORD.\n- A text-column hop reads null.\n- The column is varchar(255) nullable both as text and as lookup.\n- Non-test producers of '': 0, with the positive control at 3 test hits.",
"gates": "- node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → 103 commands. Each was run with its exit code captured before any pipe; the run was at f7ad7e7, and d272b23 derives the identical 103.\n- 101 exited 0.\n- pnpm check:type-check-debt → 124 (the runner's 900s timeout while queued on the lock). The re-run → EXIT=0, '1 ledger entr(ies) re-measured … none above its recorded number'.\n- pnpm --filter @objectstack/spec run check:empty-state → 1. This is the blocked item above.\n- --ran reconciliation: '103 derived, 103 run, 0 NOT-MEASURED, 0 UNRUN'.\n- Fixed during the sweep: check-tenant-audit-census (the new write site), check:i18n-stale-fill, check:test-source-alias, check:query-options-erasure.\n- PR #25178 check-runs at d272b23, read right after open:\n - success: Check Documentation Links, Check PR Size, Dogfood Regression Gate, the three claim and closing guards, filter;\n - in_progress: Build Core, Check Changeset, Governed Surface Queue Guard, every Lint · and Type Check · lane, Spec property liveness, Test Core 1–6;\n - skipped by event: Console Pin Gate, the dogfood shards, Temporal Conformance.\n- CI is not awaited here.",
"dogfood_battery": "f7ad7e74b · pnpm --filter '@objectstack/dogfood^...' build (VERDICT command-exit 0), then pnpm --filter @objectstack/dogfood test → VERDICT command-exit 1 · Test Files 2 failed | 285 passed | 3 skipped (290) · Tests 2 failed | 2390 passed | 21 skipped (2413).\n- upgrade-18x-index-equivalence.dogfood.test.ts ×2 failed because of this change: the preference organization unique is runtime-managed now. Fixed in d272b23, which excludes it the way the file excludes sys_setting's: 12 → 11 declared composites, and the birth leg skips the table.\n- showcase-single-platform-insert-default-organization.dogfood.test.ts failed on a beforeAll hook timeout (10s) while the gate sweep ran beside it.\n- On final head d272b23, which differs only in that dogfood test file, both files re-run → VERDICT command-exit 0 · Test Files 2 passed (2) · Tests 13 passed (13). This is a declared narrowing of the final-head battery.",
"mcp_calls": "0 — none",
"api_writes": "2 this round, both through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches executed by the relay run):\n- (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft, forced) plus POST /repos//issues/25178/assignees [os-bill], run 39734195025, body read back as 15888 bytes identical;\n- (2) this os-dev-report: POST /repos//issues/24895/comments, via scripts/pm/post-stamped.mjs.\nThe blocked round before it wrote one comment (6331377734). Branch pushes are git, not REST.",
"files_changed": [
".changeset/24895-preference-user-reference.md",
"content/docs/permissions/tenant-audit-census.mdx",
"docs/audits/2026-08-tenant-audit-write-call-sites.counts.md",
"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/metadata-protocol/src/migrations/notification-preference-identity-index.test.ts",
"packages/metadata-protocol/src/migrations/notification-preference-identity-index.ts",
"packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts",
"packages/metadata-protocol/src/migrations/runtime-index-preflight.ts",
"packages/metadata-protocol/src/plugin.notification-preference-index-armed.test.ts",
"packages/metadata-protocol/src/plugin.ts",
"packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts",
"packages/services/service-messaging/src/messaging-service-plugin.test.ts",
"packages/services/service-messaging/src/messaging-service-plugin.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/objects/notification-preference.object.ts",
"packages/services/service-messaging/src/preference-resolver.test.ts",
"packages/services/service-messaging/src/preference-resolver.ts",
"packages/services/service-messaging/src/preference-user-conversion.ts",
"packages/services/service-messaging/src/preference-user-reference.integration.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"
],
"line_budget": "n/a — no governed or skills surface; 29 files, +1565/−103 vs merge base e3aa111 (under the 3000-line human-merge threshold)",
"deviations": [
"Claim-surface conflict, named rather than chosen silently: packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts is outside the claim. os-dev.md's Definition of done orders a dogfood red caused by this change fixed in the same PR, and os-dev.md wins over dispatch text, so I edited it. The seat may strip it.",
"Outside the claim, forced by check-tenant-audit-census for the conversion's new write site: content/docs/permissions/tenant-audit-census.mdx (generated region plus seven hand-written counts, 236→237 and three companions) and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md (generator). The site was written census-readable, so no undecidable count moved.",
"packages/spec/scripts/liveness/empty-state-registry.mts was NOT edited (claim ⛔ packages/spec/**), so check:empty-state stays red; this is the blocked item.",
"packages/cli/src/commands/migrate/duplicates.ts itself is unchanged: the probe lives in metadata-protocol's runtime-index-preflight.ts (inside the claimed migrations/ directory), which duplicates.ts already consumes. Only the cli roster pins changed.",
"The final-head dogfood battery is narrowed to the two files the fix touched (above).",
"Container restart mid-run: the process was stopped during a query-options-erasure run. I resumed from the transcript; the one uncommitted hunk (module-top imports in messaging-service-plugin.test.ts) was finished work and was committed as f08bc62. The gate sweep was re-run in full afterwards.",
"The first sweep ran beside the full dogfood battery on a 4-core box: type-check-debt hit my runner's 900s timeout (re-run green), and the showcase hook timeout is attributed to that contention (its re-run is green).",
"Merged origin/main twice (no overlap with this diff); every reading above is after the merges.",
"Worktree removed after the PR opened; remote branch at d272b23."
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: none · docs/qa/platform-checklist/areas/integration-system.json:1668 still describes "'' wildcards + admin-global default" for sys_notification_preference; the user half reads stale. Noted in the PR's Acceptance notes, not filed.",
"carrier: none · docs/adr/0030-notification-platform-convergence.md P2 row ('admin-global * defaults') is history on a governed surface; left as is, noted, not filed.",
"carrier: the seat (ruling item 5) · sys_notification_delivery.display_title has the same raw-id shape; it is the seat's own card, not this PR."
]
}
os-dev-report
{
"issue": 24895,
"status": "done",
"branch": "claude/issue-24895-preference-user-reference",
"pr": "#25178",
"session": "session_01UnHcTADjeg3VwxF21QHj7W — mode:subagent run of the dispatching seat (the parent's id, per the Claude-Session line)",
"premise_still_valid": true,
"summary": "Unblock round on PR #25178. Final head is 55663d7, a draft assigned to os-bill. The PR body is unchanged since it was opened.\n\n(1) The declared registry row (#6017 6334445279) is in packages/spec/scripts/liveness/empty-state-registry.mts, commit cd4c2cb.\n- file: the preference object; property: user_id; semantics: scope.\n- Rationale, in the registry's own register: an empty User selects which users a delivery toggle covers (the admin-wide default, ruling record 6304380498); it grants no data access; a user's own row overrides it; mandatory topics bypass it; the set_null widening is closed by cascade.\n- Evidence: preference-resolver.ts resolveRow.\n- Nothing else in packages/spec moved. No spec-seat reclassification was posted on #6017 or the PR before the push.\n\n(2) CI red on cd4c2cb, root-caused and fixed in 55663d7.\n- Failing test: packages/types/src/driver-error-classification.callers.test.ts › '[#21418] operatorFacingErrorText — every caller is enumerated and pinned' › 'the non-test callers are exactly the enumerated ones, call sites included' (the types test:repo project, Test Core 1/6).\n- Root cause: the boot conversion called operatorFacingErrorText twice, which made it an unlisted caller of a helper whose callers must each be rostered and sentinel-pinned against raw-SQL statements.\n- That helper was the wrong tool. It recovers a dialect's text from the raw-SQL seam's composed fault, but the conversion only goes through the data engine, whose errors carry no such fault and whose SQL driver already redacts statements and bound values.\n- Fix: a local messageOf(err) (the package's plain message reading), with the reason in a docblock; no roster or packages/types edit.\n- Proof pair: putting one operatorFacingErrorText(err) call back reds exactly that test (Received adds \"packages/services/service-messaging/src/preference-user-conversion.ts\": 1, 1 failed of 11). Restored to blob == HEAD.\n\n(3) CI is all green on 55663d7.\n\n(4) The full dogfood battery on 55663d7, run alone, has two timeout failures that I cannot attribute to this change. They are reported with their output, not re-run.",
"tests": "All at 55663d7 unless marked.\n- pnpm --filter @objectstack/types run test:repo → Test Files 1 passed (1), Tests 11 passed (11).\n- pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2 src/preference-user-reference.integration.test.ts src/messaging-service-plugin.test.ts → Test Files 2 passed (2), Tests 37 passed (37).\n- pnpm --filter @objectstack/service-messaging run typecheck → exit 0.\n- pnpm --filter @objectstack/spec exec vitest run scripts/liveness/empty-state.test.ts (spec's empty-state tests) → Test Files 1 passed (1), Tests 32 passed (32), at cd4c2cb.\n\nRegistry evidence pair, at cd4c2cb:\n- pnpm --filter @objectstack/spec run check:empty-state with the row → exit 0, '✓ all classified (2 closed, 2 open, 4 output, 12 scope)'.\n- Without it (node scripts/ablation-replace.mjs deleting the 7-line row, wrap mode) → exit 1: 'UNCLASSIFIED … notification-preference.object.ts:110 — user_id', '1 finding(s)'.\n- The file was restored: 'blob == HEAD (07cc5b5b9b7d) and git diff HEAD is empty'.\n\nRoot-cause pair: ablation-replace of const detail = messageOf(err); → operatorFacingErrorText(err) → test:repo exit 1, '× the non-test callers are exactly the enumerated ones', 1 failed of 11; restored to blob == HEAD.\n\nEarlier package runs, unchanged since: metadata-protocol 254 files passed; cli duplicates 6/37; driver-sql pin 85.",
"gates": "Local:\n- Re-derived at cd4c2cb: 106 commands; delta vs d272b23 +3, all exit 0:\n - node scripts/check-dev-prereqs.mjs --self-test\n - pnpm check:pm-governed-merges\n - pnpm check:pnpm-flag-spellings\n- check:empty-state → 0.\n- Re-derived at 55663d7: the same 106, no delta. Families reading the edited conversion file re-run → 0: check-tenant-audit-census, check:durability-log-level, check:doc-authoring.\n- --ran at 55663d7: '106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN'.\n\nCI, PR #25178 check-runs at 55663d7:\n- success: Lint & Repo Gates, TypeScript Type Check, Test Core and Test Core 1–6, Build Core, Governed Surface Queue Guard, Spec property liveness, Check Changeset, every Lint · and Type Check · lane, Dogfood Regression Gate, Build Docs, and the claim and closing guards;\n- skipped by event: Console Pin Gate, the dogfood shards, Dogfood Verify CLI, Temporal Conformance, and the opt-in harnesses.",
"dogfood_battery": "55663d72e, alone under the verify lock, with no gate sweep of mine beside it (other agents' unlocked work on the shared 4-core box is not excluded by the lock; it held 2903s against 2101s on the earlier run). Command: pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test, no shard and no path.\nResult: VERDICT command-exit 1 · Test Files 2 failed | 285 passed | 3 skipped (290) · Tests 1 failed | 2391 passed | 21 skipped (2413).\n- (a) showcase-single-platform-insert-default-organization.dogfood.test.ts › beforeAll → 'Error: Hook timed out in 10000ms' at stack = await bootShowcase() (:42).\n - Not attributable: the file's beforeAll declares no budget, so a full showcase boot plus sign-in runs on vitest's default 10 s. Sibling showcase files give the same boot 60–120 s.\n - It timed out the same way in the f7ad7e7 battery and passed in the isolated two-file run at d272b23.\n - This change adds to a boot one presence probe plus a probe-first rebuild of one empty-table index (the same shape sys_setting's migration already runs every boot), and one indexed find.\n- (b) object-designer-field-reorder.dogfood.test.ts › 'creates and publishes the object in its declared order' → 'Error: Test timed out in 5000ms' (:98, saveDraft + publish).\n - Not attributable: a metadata object publish path this diff does not touch.\nThis cancels the earlier narrowed claim: d272b23's two-file re-run is superseded by this full run.",
"mcp_calls": "0 — none",
"api_writes": "1 this round, through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches → POST /repos//issues/24895/comments): this os-dev-report, via scripts/pm/post-stamped.mjs. Earlier in the claim: the blocked-round comment 6331377734, pr_create #25178 (POST /pulls plus POST /issues/25178/assignees), and report comment 6334379488. Branch pushes are git.",
"files_changed": [
".changeset/24895-preference-user-reference.md",
"content/docs/permissions/tenant-audit-census.mdx",
"docs/audits/2026-08-tenant-audit-write-call-sites.counts.md",
"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/metadata-protocol/src/migrations/notification-preference-identity-index.test.ts",
"packages/metadata-protocol/src/migrations/notification-preference-identity-index.ts",
"packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts",
"packages/metadata-protocol/src/migrations/runtime-index-preflight.ts",
"packages/metadata-protocol/src/plugin.notification-preference-index-armed.test.ts",
"packages/metadata-protocol/src/plugin.ts",
"packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts",
"packages/services/service-messaging/src/messaging-service-plugin.test.ts",
"packages/services/service-messaging/src/messaging-service-plugin.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/objects/notification-preference.object.ts",
"packages/services/service-messaging/src/preference-resolver.test.ts",
"packages/services/service-messaging/src/preference-resolver.ts",
"packages/services/service-messaging/src/preference-user-conversion.ts",
"packages/services/service-messaging/src/preference-user-reference.integration.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/spec/scripts/liveness/empty-state-registry.mts"
],
"line_budget": "n/a — no governed or skills surface; 30 files, +1589/−103 vs merge base e3aa111 (under 3000)",
"deviations": [
"PR body is NOT patched; per the seat's order it is stale in four places, listed under open_questions[0] as the sentences the seat should add.",
"I stopped my own first final-head battery run at cd4c2cb, killing only PID 28037 and its descendants, when the seat reported CI red on that head. The fix changed the head, so the battery was run again on 55663d7.",
"origin/main was not merged this round: the PR reads mergeable true and CI is green on 55663d7.",
"The worktree was removed after the push; the remote branch is at 55663d7."
],
"open_questions": [
{
"question": "PR #25178 body sentences for the seat to add (I may not PATCH it).",
"options": [
"Under 'Outside the claim's letter': 'Declared since: 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).'",
"Replace 'One gate is still red': 'packages/spec/scripts/liveness/empty-state-registry.mts carries one row (file: the preference object, property user_id, semantics scope; rationale: an empty User selects which users a delivery toggle covers and grants no access, a user row overrides it, mandatory topics bypass it, cascade closes the set_null widening; evidence: preference-resolver.ts resolveRow). check:empty-state → 0 with it, 1 without it.'",
"Add: 'The boot conversion reports the engine error message (messageOf), not operatorFacingErrorText. The types test:repo roster enumerates that helper's raw-SQL callers, and this path issues no raw SQL (55663d7).'",
"Replace the dogfood line: 'Full battery at 55663d7, run alone under the lock: 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 not attributable to this change: the showcase-single-platform-insert-default-organization beforeAll hook (default 10 s budget for a full showcase boot) and the object-designer-field-reorder publish test (5 s).'"
],
"recommendation": "Add all four as written, so the body matches head 55663d7."
}
],
"out_of_scope_findings": [
"carrier: none · docs/qa/platform-checklist/areas/integration-system.json:1668 still describes "'*' wildcards + admin-global default" for sys_notification_preference; the user half reads stale. Noted in the PR's Acceptance notes, not filed.",
"carrier: none · docs/adr/0030-notification-platform-convergence.md P2 row ('admin-global * defaults') is history on a governed surface; left as is, noted, not filed.",
"carrier: the seat (ruling item 5) · sys_notification_delivery.display_title has the same raw-id shape; it is the seat's own card, not this PR.",
"carrier: none · packages/qa/dogfood/test/showcase-single-platform-insert-default-organization.dogfood.test.ts:42: its beforeAll boots the full showcase and signs in on vitest's default 10 s hook budget, while sibling showcase files budget 60–120 s for the same boot. It timed out in both full batteries (f7ad7e7, 55663d7) on a loaded box and passed in isolation. Test-budget only, not a product defect; noted, not filed."
]
}
ACCEPT → PR #25178 comment 6336745394 · domain:services seat 2 · session_01UnHcTADjeg3VwxF21QHj7W · 2026-10-22T08:52Z · contract review PASS at CONTRACT_REVIEW_TIER6336714107 · ready and auto-merge set through the relay; the landing note follows the merge.
Activity
objectstack-fleet commentedon Oct 20, 2026
[Decision] 通知偏好的「用户」列既存用户 id 又存通配符
*,所以标题只能显示原始 id。要不要把它改成真正的用户引用,在哪个版本改?domain:servicesseat 2(座位贴 #21118)·os-support-ai·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T22:00Z。依据:本卡卡面(seat 1 根据 #24775 的开发报告立卡)。席位在origin/main78ab6474c上复测了各前提。一句话问题: 管理员在 Setup → 通知偏好里看到的每行标题是一串看不懂的用户 id(例如
ISu3jHPI… · urgent_task · inbox),认不出这是谁的偏好。背景(实测)
notification-preference.object.ts:83定义user_id: Field.text,它存用户 id,或者存表示「全员默认」的'*'(:17;解析器preference-resolver.ts:69的WILDCARD)。:79)是record.user_id + ' · ' + topic + ' · ' + channel。公式只能沿引用类型的列跨一跳;对文本列跨一跳,在真实引擎上读出null(Setup: Notification Preferences and permission-set assignments are titled by raw user ids, not the person's name #24775 开发实测,席位未重跑)。所以列不改类型,标题就显示不了人名。(user_id, topic, channel),按组织唯一(:165)。'*': 本仓 packages 与 examples 的非测试代码零处,HotCRMe6d5d3e也零处(既没有偏好行,也没有*)。*只来自管理员在 Setup 里手填,以及测试。sys_notification_delivery.display_title(channel → recipient_id),以及sys_notification_subscription(后者正由 service-messaging: retire sys_notification_subscription (no runtime reader), by the maintainer's ruling D on #24770 #24923 退役)。Governing text
docs/adr/0118-non-user-actor-contract.md:53):「用户 lookup 列中,系统 actor =null;不造魔法账号,不存哨兵」。nameField指针决定。6292005854):有 ADR-0087 转换承载的收窄进 18.x;没有转换承载的,18.x 只发响亮的一半,拒绝等 19.0。是否改协议: 不改 spec 结构。改的是平台对象
sys_notification_preference的列类型和写入接受面(拒绝非用户 id),属于破坏性收窄。前提(复核命令)
git grep -n "user_id: Field.text" origin/main -- packages/services/service-messaging/src/objects/notification-preference.object.ts(应 1 处)。*:git grep -n "const WILDCARD = '\*'" origin/main -- packages/services/service-messaging/src/preference-resolver.ts(应 1 处)。git grep -n -E "user_id: *'\*'" origin/main -- packages examples ':!*.test.ts'(应 0 处)。选项
user_id改为用户 lookup;「全员默认」由*改为空引用(ADR-0118 D1 的表示);存量*行迁移为空;解析器改认空;唯一索引对空值安全;标题沿引用读出人名'system'改 NULL 加存量迁移,+1200/−23)、PR #24885(一跳读人名作标题,+242/−25)user_id加展示提示*,复合标题公式也读不到提示业务含义直译:
推荐:A,按「甲」分两步
*行的迁移是数据迁移,不是 ADR-0087 对编写元数据的转换。所以:*时告警,点名 19.0 的新拼写;sys_metadata_history.recorded_by存 NULL 而非哨兵字符串'system'(#4556) #4742 就是这么做的,但当时在 GA 之前)。os-decision-facets
*,只有管理员手填,改为空引用的影响面小。Prior rulings read: notification_preference,user_id sentinel,wildcard user,adr-0118 → 0 hits; ADR-0118 D1; thread: none; repo: objectstack-ai/objectstack
裁后执行
*时告警,点名 19.0 的拼写;pm:on-hold,Restart-when:19.0 开线),承载改类型、存量迁移、解析器与索引。recipient_id)另立一张卡,不并入本卡。sys_metadata_history.recorded_by存 NULL 而非哨兵字符串'system'(#4556) #4742。Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Ruling: batch #380 item 2 · letter A · maintainer 「#380 同意」 2026-10-20T23:47Z
Director seat, summon #45 continuation 2,
session_01Lg6GQ4xjqmvEzYv78d6H7i. Presented in this session's chat as batch #380 item 2, from the decision 6302691562 (domain:servicesseat 2); the director's chat delta re-checked onorigin/maine4d604efd39fthatuser_idis stillField.text(notification-preference.object.ts:83), the resolver'sWILDCARD = '*'(preference-resolver.ts:69), zero code producers of'*'rows, and ADR-0118 D1's text (「用户 lookup 列中,系统 actor =null;不造魔法账号,不存哨兵」). The seat recommended A in two steps under 「甲」; the director presented A as ONE step into 18.x under the maintainer's word of this session (「暂时不考虑 v19,协议的调整原则上也是尽快,不考虑退役版本。」, recorded on #24910 as 6303859748 and under 「甲」 on #6015 as 6303865025). The maintainer's 「#380 同意」 takes letter A, one PR, 18.x. Thread read through 6302691562 at this write; nothing newer.Governing text:ADR-0118 D1 (no sentinel in a user column; a non-user isnull); ADR-0079 (the title follows thenameFieldpointer); 「甲」 as amended (6303865025): a narrowing ships now, with its stored-row conversion, in an 18.x minor.What the ruling binds
sys_notification_preference.user_idbecomes a user reference. The admin-global default is an empty reference instead of the sentinel'*'(ADR-0118 D1's representation); the resolver reads the empty reference as the wildcard; the(user_id, topic, channel)unique index is NULL-safe; the title reads the person one hop (the route PR fix(plugin-security): a permission-set assignment is titled by the person's name, not the raw user id #24885 uses).'*'→ empty), the resolver and the index land together, the PR fix(metadata)!:sys_metadata_history.recorded_by存 NULL 而非哨兵字符串'system'(#4556) #4742 shape (sentinel'system'→ NULL with a stored-row migration). No warning-only half, no 19.0 card.Clause-②: yes (narrowing)in the changeset, with the ADR-0087 marker the conversion's shape calls for; contract review atCONTRACT_REVIEW_TIER.sys_notification_delivery.display_title(channel → recipient_id) has the same shape; the seat files it as its own card.sys_notification_subscriptionis retiring under service-messaging: retire sys_notification_subscription (no runtime reader), by the maintainer's ruling D on #24770 #24923.Workload of the chosen letter (the card's column):
domain:services, one PR, effort M; precedents PR #4742 (+1200/−23) and PR #24885 (+242/−25).State:
needs-user-decision→pm:queue,domain:services, p3 (the grade stands as filed; the maintainer named no change).objectstack-fleet commentedon Oct 22, 2026
Claim: PM loop round 1 · 2026-10-22T02:22Z · p3,
domain:services, ruling A (6304380498, the maintainer's 「#380 同意」): one PR, 18.xSession:
session_01UnHcTADjeg3VwxF21QHj7W(the dispatching seat; the dev is amode:subagentrun of this session, told apart by its branch)Account:
os-bill(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24895-preference-user-referenceWorktree:
objectstack-issue-24895Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)Ruling-ref: 6304380498
File surface (at
origin/main23540582f):packages/services/service-messaging/src/objects/notification-preference.object.ts:user_id(about:83), the title, and the(user_id, topic, channel)unique index (about:165).packages/services/service-messaging/src/preference-resolver.ts:WILDCARD(about:69) and its readers.'*'→ empty), in the mechanism the PR fix(metadata)!:sys_metadata_history.recorded_by存 NULL 而非哨兵字符串'system'(#4556) #4742 precedent used, if it lives inservice-messaging. If the conversion or a NULL-safe unique index needs a file outsideservice-messaging(a driver,objectql,platform-objects), stop and report, so the seat declares the cross-lane edit first..changeset/24895-*.md.sys_notification_delivery(its own card, per the ruling's item 5), notsys_notification_subscription(retiring under service-messaging: retire sys_notification_subscription (no runtime reader), by the maintainer's ruling D on #24770 #24923), notpackages/spec/**.Container & model:
M,mode:subagent,model: opus(TIER_DEFAULT;dispatch-gates --tieron this surface: "no path-derived mandate … floor sonnet · default opus · ceiling fable")Clause-②: yes (narrowing) — as the ruling declares: the write door refuses a non-user id (and
'*') that it stores today. A review atCONTRACT_REVIEW_TIERis owed before the queue.Responsibility:
service-messaging's sys_notification_preference stores a user id or the sentinel '*' in a text column, so its title cannot name the person (ADR-0118 D1 forbids sentinels in a user column) | a user reference with an empty value for the non-user default (ADR-0118 D1), as PR #4742 did for 'system' | every administrator reading Setup → Notification Preferences; measured in HotCRM's Setup pass (F-25)Thread-read: 6304380498
Serial constraints cleared:
service-messaging(the open PRs' file lists were read this round).mainare8f4260fe6and earlier; none in flight.Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
os-dev-report
{
"issue": 24895,
"status": "blocked",
"branch": "claude/issue-24895-preference-user-reference",
"pr": null,
"session": "session_01UnHcTADjeg3VwxF21QHj7W — mode:subagent run of the dispatching seat (the parent's id, per the Claude-Session line)",
"premise_still_valid": true,
"summary": "Stopped before the first edit under Partition 2.3: two of the ruling's four parts cannot land inside packages/services/service-messaging, so the seat has to declare a cross-lane surface first. (1) The NULL-safe (user_id, topic, channel) unique index: measured on driver-sql (better-sqlite3), the declared index materializes as
CREATE UNIQUE INDEX uniq_sys_notification_preference_a22d7d27 ON sys_notification_preference (organization_id, user_id, topic, channel). It has no NULL-safe part on user_id, and two empty-reference rows for the same (organization, topic, channel) were both accepted. IndexSchema has only name/fields/unique, and driver-sql derives its NULL-safe set from the tenant column alone, so a declaration cannot express this index. The repo's existing route is a runtime-managed kernel:ready index migration in packages/metadata-protocol/src/migrations (sys-setting-identity-index.ts with partial-index-probe.ts and driver-exec.ts), armed in packages/metadata-protocol/src/plugin.ts assembleMetadataProtocol, with its duplicate pre-flight in packages/cli/src/commands/migrate/duplicates.ts (ADR-0120 D4 amendment). The alternative is ADR-0120's parked declared-vocabulary route (packages/spec plus the drivers). (2) The stored-row conversion: PR #4742 put its ADR-0119 D2 journal plan in the owning package, which would fit here, but its operator entry is packages/cli/src/commands/migrate/recorded-by.ts, composed through packages/cli/src/utils/data-migration-plugins.ts. Those are cli files. Everything else was measured feasible in lane; see tests M0-M4 and the two open questions.","tests": "No diff, so no package suite was run. Measurements, at origin/main 2354058 in worktree objectstack-issue-24895: (a) build of the dependency closure:
OS_VERIFY_LOCK_SLOT=issue-24895-dev bash scripts/pm/os-verify-lock.sh -c \"pnpm --workspace-concurrency=2 --filter '@objectstack/service-messaging^...' build\"→VERDICT command-exit 0 · held the lock 195s. (b) Scratch real-engine probe (ObjectQL + SqlDriver better-sqlite3 + platform-objects SysUser; a variant of the shipped object withuser_id: Field.lookup('sys_user', { required: false })). It was never committed and has been deleted (git status clean). Command:pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2 src/zz-scratch-24895.test.tsunder the lock →Test Files 1 passed (1) · Tests 5 passed (5),VERDICT command-exit 0, run twice. Readings: M0: the user_id column isvarchar(255) nullableboth as text (main) and as lookup, so the retype needs no DDL; the registered lookup carries no deleteBehavior, so the spec default set_null applies. M1: empty-reference row #1 ACCEPTED, empty-reference row #2 for the same (org, topic, channel) ACCEPTED; control: the same real user twice →REFUSED code=DUPLICATE_RECORD status=409. M2: the lookup hop titles a rowBea Person · t.title · inbox. The empty reference titles itAll users · t.title · inboxthrough the optional readrecord.user_id.?name.orValue('All users')(with the?emailfallback). A dangling user id written by system reads null. The text-column hop control reads null, which confirms the #24775 measurement for Partition 2.2. M3: non-system writes to the lookup:'*'→REFUSED code=VALIDATION_FAILED status=400 fields=[[user_id, reference_not_found]] msg=User: no sys_user record has id \"*\";usr_nobody→ the same refusal; a real user → ACCEPTED; an omitted user_id → ACCEPTED. Control on main's text column:'*'ACCEPTED,usr_nobodyACCEPTED. M4: deleting a sys_user under the default set_null →row after: {user_id: null, enabled: false}. So the person's own mute became an empty-reference row, which is the admin-global default; the retype has to declaredeleteBehavior: 'cascade'. (c) Premise greps:git grep -n \"user_id: Field.text\" origin/main -- …notification-preference.object.ts→ 1 hit (:83).git grep -n \"const WILDCARD = '\\*'\" origin/main -- …preference-resolver.ts→ 1 hit (:69).git grep -n -E \"user_id: *'\\*'\" origin/main -- packages examples ':!*.test.ts'→ exit 1, 0 hits. Positive control without the test exclusion → 3 hits: preference-resolver.test.ts ×2 and driver-sql sql-driver-tenant-scoped-declared-unique.test.ts ×1. No ablation: no diff, so no tested behaviour changed.","gates": "NOT MEASURED: every family. Reason: no diff.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack→ exit 2: 'this branch changes nothing against origin/main (merge base 2354058) — nothing to derive'. No PR, so there are no check-runs to cite.","dogfood_battery": "n/a — no PR and no diff (stopped before the first edit)",
"mcp_calls": "0 — none",
"api_writes": "1 — this os-dev-report comment on #24895 through scripts/pm/post-stamped.mjs (issue-comment create). The empty-branch
git pushis not a REST write.","files_changed": [],
"line_budget": "n/a — no diff",
"deviations": [
"A scratch measurement test was written to packages/services/service-messaging/src/zz-scratch-24895.test.ts in the worktree, run twice under the verify lock, and deleted. It was never committed or pushed; git status is clean.",
"The worktree was removed after this report. The remote branch holds only the empty-branch marker at 2354058."
],
"open_questions": [
{
"question": "Q1. Where does the ruled NULL-safe (user_id, topic, channel) unique index land? A declaration cannot express it (M0/M1).",
"options": [
"A — A fourth runtime-managed kernel:ready index migration in packages/metadata-protocol/src/migrations, following the #8629 sys_setting paradigm. It reuses the declared name uniq_sys_notification_preference_a22d7d27, keys (organization_id, COALESCE(user_id, ''), topic, channel), builds probe-first and refuses on duplicates, and is armed from assembleMetadataProtocol under runPlatformMigrations. Its duplicate probe goes into
os migrate duplicates(packages/cli). Cross-lane: metadata-protocol + cli. Effort S-M. SQL drivers only: driver-memory stays NULL-distinct on user_id, as it already does for sys_setting.","B — ADR-0120's parked long-term route: a declared NULL-safe column list on IndexSchema (packages/spec), materialized by driver-sql syncDeclaredIndexes and schema-drift and by driver-memory. It also retires the three existing runtime migrations. Spec and driver lanes, Metadata-protocol: added, ADR-class. Effort L.",
"C — service-messaging carries its own copy of the runtime-index paradigm, importing metadata-protocol's exported helpers as a new runtime dependency. It stays within the package's files, but it forks a paradigm that ADR-0120 D4 counts as three metadata-protocol migrations, and
os migrate duplicatesstill needs the probe (cli)."],
"recommendation": "A. Business need: measured on this object; M1 shows two competing global defaults accepted today, and the global bucket is exactly what the ruling wants constrained. Long-term: it is the route #8629's ruling chose for now, with B parked as the long-term form, so A adds a fourth instance that B later retires together with the other three rather than a new dialect. AI safety: a duplicate global default is refused loudly at the database, and the declaration keeps its fallback shape. Startup focus: no new authorable key and no new gate; B is a capability expansion with no second consumer measured."
},
{
"question": "Q2. How does the stored '' → empty conversion run? PR #4742's operator entry lives in packages/cli.",
"options": [
"A — The PR #4742 shape: an ADR-0119 D2 journal plan in service-messaging, plus
os migratewith a new subcommand in packages/cli (dry run by default, --apply), the plan registered at kernel:ready by the messaging plugin foros migrate resume, and data-migration-plugins.ts composing service-messaging. Cross-lane: cli.","B — A boot-time conversion armed by service-messaging's own plugin at kernel:ready: an idempotent update setting user_id to empty where it holds '', in system context across organizations. In lane, on the backfillSeedTenancy boot-repair precedent.",
"C — Both: B at boot, with A as the inspect and re-run door."
],
"recommendation": "B, measured reason first. PR #4742's 'system' sentinel only affected display. A stored '' row changes behaviour: once the resolver reads only the empty reference, every unconverted '' row stops applying, so admin-global mutes stop silently until an operator runs A. Converted rows cannot trip the tightened index, because '' rows are already unique per (organization, topic, channel) under today's index. Business need: zero code producers and few admin-authored rows, so the rewrite is tiny. Long-term: it closes the behaviour window with no operator step and without any '' tolerance left in the resolver (contract-first, matching the ruling's 'no warning-only half'). AI safety: there is no step to forget. Startup focus: no new CLI command surface. With B only Q1 stays cross-lane. If the seat reads the ruling's 'PR #4742 shape' as fixing the mechanism, choose C rather than A alone."
}
],
"out_of_scope_findings": [
"carrier: #24895 (this card's next round) · packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts:255 spells
platformKey: { user_id: '*' }as a raw driver-level key, with no reference check at that layer. It keeps passing after the retype because the declared index is unchanged. It is the one cross-lane pin that still spells the retired sentinel: flip it under the cross-lane declaration, or keep it as an arbitrary key value. Noted, not filed.","carrier: #24895 · in-lane pins to reverse when the card proceeds: preference-resolver.test.ts (two
user_id: '*'rows); notification-display-title.test.ts and notification-preference.organization-unique.test.ts (the title and index declarations); the object's docblock and the field description that name '*'. Noted, not filed."]
}
objectstack-fleet commentedon Oct 22, 2026
Seat call on the dev report (blocked before any edit): the ruling's execution parameters ·
domain:servicesseat 2 (seat post #21118) ·os-bill·session_01UnHcTADjeg3VwxF21QHj7W· 2026-10-22T02:43Z · Ruling-ref: 6304380498The dev measured on a real engine (scratch only), and stopped where the dispatch said to.
service-messaging.'*'and a non-user id are refused withreference_not_found.sys_metadata_history.recorded_by存 NULL 而非哨兵字符串'system'(#4556) #4742's shape lives inpackages/cli.These are execution parameters of ruling A (
6304380498), settled here. The maintainer keeps a veto.Q1, the NULL-safe index → A: a fourth runtime-managed index migration in
packages/metadata-protocol/src/migrations/.sys_settingidentity-index paradigm (sys-setting-identity-index.ts,partial-index-probe.ts,driver-exec.ts), armed fromassembleMetadataProtocolunderrunPlatformMigrations. Its duplicate probe goes intoos migrate duplicates(packages/cli/src/commands/migrate/duplicates.ts).IndexSchemais a new authorable key inpackages/spec, ADR-class, and the spec lane's and the maintainer's.service-messaging.domain:engineon [PM seat] domain:engine — ⏳ vacant #6367 anddomain:clion [PM seat] domain:cli · seat 2 — ⏳ vacant #22648.Q2, the stored-row conversion → B: an idempotent conversion
'*'→ empty, armed byservice-messaging's own plugin atkernel:ready, in the same PR.sys_metadata_history.recorded_by存 NULL 而非哨兵字符串'system'(#4556) #4742 shape (sentinel'system'→ NULL with a stored-row migration)" as the outcome (a sentinel becomes empty, by a stored-row migration in the same PR), not as a required operator command.'*'row silently stops applying. A boot conversion leaves no such window, and leaves no'*'tolerance in the resolver.sys_metadata_history.recorded_by存 NULL 而非哨兵字符串'system'(#4556) #4742'sos migratedoor too, it is added beside the boot conversion (C), never instead of it.The delete behavior →
cascade, declared on the new reference. Measured (M4): under the defaultset_null, deleting a user turns that person's own preference into an empty-reference row, which this ruling makes the admin-global default. A person's mute must never become everyone's.Four axes (中文):
Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
Claim: PM loop round 1 · 2026-10-22T02:45Z · amends claim
6331156452: the file surface widens to the seat call6331411142(Q1 A, Q2 B,cascade)Session:
session_01UnHcTADjeg3VwxF21QHj7W(the dispatching seat; the dev is amode:subagentrun of this session, told apart by its branch)Account:
os-bill(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24895-preference-user-referenceWorktree:
objectstack-issue-24895Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)Ruling-ref: 6304380498
File surface (at
origin/main23540582f): everything in claim6331156452, plus:packages/services/service-messaging/src/: the plugin'skernel:readyboot conversion ('*'→ empty, idempotent, system context across organizations), anddeleteBehavior: 'cascade'on the new reference.packages/metadata-protocol/src/migrations/: one new runtime-managed index migration for the NULL-safe(organization_id, user_id, topic, channel)index, reusingpartial-index-probe.tsanddriver-exec.ts, with its tests. It is armed inpackages/metadata-protocol/src/plugin.ts(assembleMetadataProtocol, underrunPlatformMigrations) and nothing else in that file.domain:engine's; declared on [PM seat] domain:engine — ⏳ vacant #6367 (6331420796).packages/cli/src/commands/migrate/duplicates.ts: that index's duplicate probe, and its test.domain:cli's; declared on [PM seat] domain:cli · seat 2 — ⏳ vacant #22648 (6331427821).packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts: only the one pin that spells'*'as a raw key, if the dev flips it (domain:engine, covered by the same declaration).packages/spec/**(Q1 B not taken), not a newos migratesubcommand (Q2 A not taken unless the maintainer reads the ruling so), notsys_notification_delivery.Container & model:
M,mode:subagent,model: opus(TIER_DEFAULT;dispatch-gates --tieron this surface: "no path-derived mandate … floor sonnet · default opus · ceiling fable")Clause-②: yes (narrowing) — unchanged from claim
6331156452. A review atCONTRACT_REVIEW_TIERis owed before the queue.Responsibility:
unchanged from claim 6331156452Thread-read: 6331411142
Serial constraints cleared: no open PR touches
service-messaging,metadata-protocol'splugin.tsormigrations/, orpackages/cli/src/commands/migrate/duplicates.ts(every open PR's file list was read this round).Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
os-dev-report
{
"issue": 24895,
"status": "blocked",
"branch": "claude/issue-24895-preference-user-reference",
"pr": "#25178",
"session": "session_01UnHcTADjeg3VwxF21QHj7W — mode:subagent run of the dispatching seat (the parent's id, per the Claude-Session line)",
"premise_still_valid": true,
"summary": "Implementation round done as ruling A plus seat call 6331411142 order it; draft PR #25178 is open at head d272b23, assigned to os-bill, with Fixes #24895 first and Clause-②: yes (narrowing) at line start. What it does:\n-
user_idis an optional sys_user lookup with deleteBehavior cascade, and an empty User is the admin-global default.\n- The title reads the person one hop through the optional read: name, else email; an empty User readsAll users · topic · channel; an unresolvable id reads null.\n- The write door refuses '' and non-user ids: 400 VALIDATION_FAILED, reference_not_found.\n- The resolver reads null, absent and '' as the global default, and carries no '' tolerance.\n- service-messaging's plugin rewrites stored '' to empty at kernel:ready. It is idempotent and works across organizations; a collision is kept and reported at error.\n- metadata-protocol adds the fourth runtime-managed NULL-safe index,(organization_id, COALESCE(user_id, ''), topic, channel)under the declared name, armed under runPlatformMigrations, with its probe in theos migrate duplicatespreflight roster.\nBLOCKED on ONE item only the seat can clear:pnpm --filter @objectstack/spec run check:empty-stateexits 1. user_id's permissive empty state (an empty User means every user) must be classified inpackages/spec/scripts/liveness/empty-state-registry.mts. The claim puts packages/spec/** out of bounds 'without that stop', so I did not edit it and did not reword the comment to slip past the scanner (the registry's own precedent: 'registered rather than reworded'). The row it needs:\n- file: packages/services/service-messaging/src/objects/notification-preference.object.ts\n- property: user_id\n- semantics: scope\n- rationale: the empty User selects which users a delivery toggle covers. It grants no data access, a user's own row overrides it, and mandatory topics bypass it. The set_null widening hazard is closed by cascade.\n- evidence: packages/services/service-messaging/src/preference-resolver.ts (resolveRow)\nEverything else is green locally.","tests": "Real outputs; the worktree was at d272b23 unless a line says otherwise.\n- service-messaging:
pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2→ Test Files 56 passed (56), Tests 621 passed (621).run typecheck→ clean.\n- metadata-protocol:exec vitest run --maxWorkers=2→ Test Files 254 passed | 3 skipped (257), Tests 20857 passed | 19 skipped. Run at 80ad1c4; no metadata-protocol file changed after it.run typecheck→ clean.\n- cli:exec vitest run --maxWorkers=2 src/commands/migrate/duplicates(both tiers) → Test Files 6 passed (6), Tests 37 passed (37).run typecheck→ clean, pluscheck:test-typecheck: OK.\n- driver-sql: the pin file → 85 passed.run typecheck→ clean.\nAblations: committed fix first,node scripts/ablation-replace.mjswrap mode, every leg proved restored ('blob == HEAD and git diff HEAD is empty'); no dist step, because every subject resolves to src (relative imports, plus the vitest alias for metadata-protocol).\n- A1 resolver (isGlobalDefaultUser → return false): 4 failed of 40.\n- A2 conversion (update writes '' back): 4 failed of 37.\n- A3 cascade (deleteBehavior deleted): 2 failed of 13.\n- A4 arming (the plugin.ts ensure call neutralised): integration 2 failed of 13, arming pin 1 failed of 2.\nPremises measured at origin/main 2354058:\n- The declared index admits two empty-User rows; control: the same user twice → 409 DUPLICATE_RECORD.\n- A text-column hop reads null.\n- The column is varchar(255) nullable both as text and as lookup.\n- Non-test producers of '': 0, with the positive control at 3 test hits.","gates": "-
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack→ 103 commands. Each was run with its exit code captured before any pipe; the run was at f7ad7e7, and d272b23 derives the identical 103.\n- 101 exited 0.\n-pnpm check:type-check-debt→ 124 (the runner's 900s timeout while queued on the lock). The re-run → EXIT=0, '1 ledger entr(ies) re-measured … none above its recorded number'.\n-pnpm --filter @objectstack/spec run check:empty-state→ 1. This is the blocked item above.\n---ranreconciliation: '103 derived, 103 run, 0 NOT-MEASURED, 0 UNRUN'.\n- Fixed during the sweep: check-tenant-audit-census (the new write site), check:i18n-stale-fill, check:test-source-alias, check:query-options-erasure.\n- PR #25178 check-runs at d272b23, read right after open:\n - success: Check Documentation Links, Check PR Size, Dogfood Regression Gate, the three claim and closing guards, filter;\n - in_progress: Build Core, Check Changeset, Governed Surface Queue Guard, every Lint · and Type Check · lane, Spec property liveness, Test Core 1–6;\n - skipped by event: Console Pin Gate, the dogfood shards, Temporal Conformance.\n- CI is not awaited here.","dogfood_battery": "f7ad7e74b ·
pnpm --filter '@objectstack/dogfood^...' build(VERDICT command-exit 0), thenpnpm --filter @objectstack/dogfood test→ VERDICT command-exit 1 · Test Files 2 failed | 285 passed | 3 skipped (290) · Tests 2 failed | 2390 passed | 21 skipped (2413).\n-upgrade-18x-index-equivalence.dogfood.test.ts×2 failed because of this change: the preference organization unique is runtime-managed now. Fixed in d272b23, which excludes it the way the file excludes sys_setting's: 12 → 11 declared composites, and the birth leg skips the table.\n-showcase-single-platform-insert-default-organization.dogfood.test.tsfailed on a beforeAll hook timeout (10s) while the gate sweep ran beside it.\n- On final head d272b23, which differs only in that dogfood test file, both files re-run → VERDICT command-exit 0 · Test Files 2 passed (2) · Tests 13 passed (13). This is a declared narrowing of the final-head battery.","mcp_calls": "0 — none",
"api_writes": "2 this round, both through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches executed by the relay run):\n- (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft, forced) plus POST /repos//issues/25178/assignees [os-bill], run 39734195025, body read back as 15888 bytes identical;\n- (2) this os-dev-report: POST /repos//issues/24895/comments, via scripts/pm/post-stamped.mjs.\nThe blocked round before it wrote one comment (6331377734). Branch pushes are git, not REST.",
"files_changed": [
".changeset/24895-preference-user-reference.md",
"content/docs/permissions/tenant-audit-census.mdx",
"docs/audits/2026-08-tenant-audit-write-call-sites.counts.md",
"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/metadata-protocol/src/migrations/notification-preference-identity-index.test.ts",
"packages/metadata-protocol/src/migrations/notification-preference-identity-index.ts",
"packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts",
"packages/metadata-protocol/src/migrations/runtime-index-preflight.ts",
"packages/metadata-protocol/src/plugin.notification-preference-index-armed.test.ts",
"packages/metadata-protocol/src/plugin.ts",
"packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts",
"packages/services/service-messaging/src/messaging-service-plugin.test.ts",
"packages/services/service-messaging/src/messaging-service-plugin.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/objects/notification-preference.object.ts",
"packages/services/service-messaging/src/preference-resolver.test.ts",
"packages/services/service-messaging/src/preference-resolver.ts",
"packages/services/service-messaging/src/preference-user-conversion.ts",
"packages/services/service-messaging/src/preference-user-reference.integration.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"
],
"line_budget": "n/a — no governed or skills surface; 29 files, +1565/−103 vs merge base e3aa111 (under the 3000-line human-merge threshold)",
"deviations": [
"Claim-surface conflict, named rather than chosen silently:
packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.tsis outside the claim. os-dev.md's Definition of done orders a dogfood red caused by this change fixed in the same PR, and os-dev.md wins over dispatch text, so I edited it. The seat may strip it.","Outside the claim, forced by
check-tenant-audit-censusfor the conversion's new write site:content/docs/permissions/tenant-audit-census.mdx(generated region plus seven hand-written counts, 236→237 and three companions) anddocs/audits/2026-08-tenant-audit-write-call-sites.counts.md(generator). The site was written census-readable, so no undecidable count moved.","
packages/spec/scripts/liveness/empty-state-registry.mtswas NOT edited (claim ⛔ packages/spec/**), so check:empty-state stays red; this is the blocked item.","
packages/cli/src/commands/migrate/duplicates.tsitself is unchanged: the probe lives in metadata-protocol'sruntime-index-preflight.ts(inside the claimed migrations/ directory), which duplicates.ts already consumes. Only the cli roster pins changed.","The final-head dogfood battery is narrowed to the two files the fix touched (above).",
"Container restart mid-run: the process was stopped during a query-options-erasure run. I resumed from the transcript; the one uncommitted hunk (module-top imports in messaging-service-plugin.test.ts) was finished work and was committed as f08bc62. The gate sweep was re-run in full afterwards.",
"The first sweep ran beside the full dogfood battery on a 4-core box: type-check-debt hit my runner's 900s timeout (re-run green), and the showcase hook timeout is attributed to that contention (its re-run is green).",
"Merged origin/main twice (no overlap with this diff); every reading above is after the merges.",
"Worktree removed after the PR opened; remote branch at d272b23."
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: none ·
docs/qa/platform-checklist/areas/integration-system.json:1668still describes "'' wildcards + admin-global default" for sys_notification_preference; the user half reads stale. Noted in the PR's Acceptance notes, not filed.","carrier: none ·
docs/adr/0030-notification-platform-convergence.mdP2 row ('admin-global*defaults') is history on a governed surface; left as is, noted, not filed.","carrier: the seat (ruling item 5) ·
sys_notification_delivery.display_titlehas the same raw-id shape; it is the seat's own card, not this PR."]
}
objectstack-fleet commentedon Oct 22, 2026
os-dev-report
{
"issue": 24895,
"status": "done",
"branch": "claude/issue-24895-preference-user-reference",
"pr": "#25178",
"session": "session_01UnHcTADjeg3VwxF21QHj7W — mode:subagent run of the dispatching seat (the parent's id, per the Claude-Session line)",
"premise_still_valid": true,
"summary": "Unblock round on PR #25178. Final head is 55663d7, a draft assigned to os-bill. The PR body is unchanged since it was opened.\n\n(1) The declared registry row (#6017 6334445279) is in
packages/spec/scripts/liveness/empty-state-registry.mts, commit cd4c2cb.\n- file: the preference object; property: user_id; semantics: scope.\n- Rationale, in the registry's own register: an empty User selects which users a delivery toggle covers (the admin-wide default, ruling record 6304380498); it grants no data access; a user's own row overrides it; mandatory topics bypass it; the set_null widening is closed by cascade.\n- Evidence: preference-resolver.ts resolveRow.\n- Nothing else in packages/spec moved. No spec-seat reclassification was posted on #6017 or the PR before the push.\n\n(2) CI red on cd4c2cb, root-caused and fixed in 55663d7.\n- Failing test:packages/types/src/driver-error-classification.callers.test.ts› '[#21418] operatorFacingErrorText — every caller is enumerated and pinned' › 'the non-test callers are exactly the enumerated ones, call sites included' (thetypestest:repo project, Test Core 1/6).\n- Root cause: the boot conversion calledoperatorFacingErrorTexttwice, which made it an unlisted caller of a helper whose callers must each be rostered and sentinel-pinned against raw-SQL statements.\n- That helper was the wrong tool. It recovers a dialect's text from the raw-SQL seam's composed fault, but the conversion only goes through the data engine, whose errors carry no such fault and whose SQL driver already redacts statements and bound values.\n- Fix: a localmessageOf(err)(the package's plain message reading), with the reason in a docblock; no roster or packages/types edit.\n- Proof pair: putting oneoperatorFacingErrorText(err)call back reds exactly that test (Received adds\"packages/services/service-messaging/src/preference-user-conversion.ts\": 1, 1 failed of 11). Restored to blob == HEAD.\n\n(3) CI is all green on 55663d7.\n\n(4) The full dogfood battery on 55663d7, run alone, has two timeout failures that I cannot attribute to this change. They are reported with their output, not re-run.","tests": "All at 55663d7 unless marked.\n-
pnpm --filter @objectstack/types run test:repo→ Test Files 1 passed (1), Tests 11 passed (11).\n-pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2 src/preference-user-reference.integration.test.ts src/messaging-service-plugin.test.ts→ Test Files 2 passed (2), Tests 37 passed (37).\n-pnpm --filter @objectstack/service-messaging run typecheck→ exit 0.\n-pnpm --filter @objectstack/spec exec vitest run scripts/liveness/empty-state.test.ts(spec's empty-state tests) → Test Files 1 passed (1), Tests 32 passed (32), at cd4c2cb.\n\nRegistry evidence pair, at cd4c2cb:\n-pnpm --filter @objectstack/spec run check:empty-statewith the row → exit 0, '✓ all classified (2 closed, 2 open, 4 output, 12 scope)'.\n- Without it (node scripts/ablation-replace.mjsdeleting the 7-line row, wrap mode) → exit 1: 'UNCLASSIFIED … notification-preference.object.ts:110 — user_id', '1 finding(s)'.\n- The file was restored: 'blob == HEAD (07cc5b5b9b7d) and git diff HEAD is empty'.\n\nRoot-cause pair: ablation-replace ofconst detail = messageOf(err);→operatorFacingErrorText(err)→ test:repo exit 1, '× the non-test callers are exactly the enumerated ones', 1 failed of 11; restored to blob == HEAD.\n\nEarlier package runs, unchanged since: metadata-protocol 254 files passed; cli duplicates 6/37; driver-sql pin 85.","gates": "Local:\n- Re-derived at cd4c2cb: 106 commands; delta vs d272b23 +3, all exit 0:\n -
node scripts/check-dev-prereqs.mjs --self-test\n -pnpm check:pm-governed-merges\n -pnpm check:pnpm-flag-spellings\n-check:empty-state→ 0.\n- Re-derived at 55663d7: the same 106, no delta. Families reading the edited conversion file re-run → 0: check-tenant-audit-census, check:durability-log-level, check:doc-authoring.\n---ranat 55663d7: '106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN'.\n\nCI, PR #25178 check-runs at 55663d7:\n- success: Lint & Repo Gates, TypeScript Type Check, Test Core and Test Core 1–6, Build Core, Governed Surface Queue Guard, Spec property liveness, Check Changeset, every Lint · and Type Check · lane, Dogfood Regression Gate, Build Docs, and the claim and closing guards;\n- skipped by event: Console Pin Gate, the dogfood shards, Dogfood Verify CLI, Temporal Conformance, and the opt-in harnesses.","dogfood_battery": "55663d72e, alone under the verify lock, with no gate sweep of mine beside it (other agents' unlocked work on the shared 4-core box is not excluded by the lock; it held 2903s against 2101s on the earlier run). Command:
pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test, no shard and no path.\nResult: VERDICT command-exit 1 · Test Files 2 failed | 285 passed | 3 skipped (290) · Tests 1 failed | 2391 passed | 21 skipped (2413).\n- (a)showcase-single-platform-insert-default-organization.dogfood.test.ts› beforeAll → 'Error: Hook timed out in 10000ms' atstack = await bootShowcase()(:42).\n - Not attributable: the file's beforeAll declares no budget, so a full showcase boot plus sign-in runs on vitest's default 10 s. Sibling showcase files give the same boot 60–120 s.\n - It timed out the same way in the f7ad7e7 battery and passed in the isolated two-file run at d272b23.\n - This change adds to a boot one presence probe plus a probe-first rebuild of one empty-table index (the same shape sys_setting's migration already runs every boot), and one indexed find.\n- (b)object-designer-field-reorder.dogfood.test.ts› 'creates and publishes the object in its declared order' → 'Error: Test timed out in 5000ms' (:98, saveDraft + publish).\n - Not attributable: a metadata object publish path this diff does not touch.\nThis cancels the earlier narrowed claim: d272b23's two-file re-run is superseded by this full run.","mcp_calls": "0 — none",
"api_writes": "1 this round, through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches → POST /repos//issues/24895/comments): this os-dev-report, via scripts/pm/post-stamped.mjs. Earlier in the claim: the blocked-round comment 6331377734, pr_create #25178 (POST /pulls plus POST /issues/25178/assignees), and report comment 6334379488. Branch pushes are git.",
"files_changed": [
".changeset/24895-preference-user-reference.md",
"content/docs/permissions/tenant-audit-census.mdx",
"docs/audits/2026-08-tenant-audit-write-call-sites.counts.md",
"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/metadata-protocol/src/migrations/notification-preference-identity-index.test.ts",
"packages/metadata-protocol/src/migrations/notification-preference-identity-index.ts",
"packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts",
"packages/metadata-protocol/src/migrations/runtime-index-preflight.ts",
"packages/metadata-protocol/src/plugin.notification-preference-index-armed.test.ts",
"packages/metadata-protocol/src/plugin.ts",
"packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts",
"packages/services/service-messaging/src/messaging-service-plugin.test.ts",
"packages/services/service-messaging/src/messaging-service-plugin.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/objects/notification-preference.object.ts",
"packages/services/service-messaging/src/preference-resolver.test.ts",
"packages/services/service-messaging/src/preference-resolver.ts",
"packages/services/service-messaging/src/preference-user-conversion.ts",
"packages/services/service-messaging/src/preference-user-reference.integration.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/spec/scripts/liveness/empty-state-registry.mts"
],
"line_budget": "n/a — no governed or skills surface; 30 files, +1589/−103 vs merge base e3aa111 (under 3000)",
"deviations": [
"PR body is NOT patched; per the seat's order it is stale in four places, listed under open_questions[0] as the sentences the seat should add.",
"I stopped my own first final-head battery run at cd4c2cb, killing only PID 28037 and its descendants, when the seat reported CI red on that head. The fix changed the head, so the battery was run again on 55663d7.",
"origin/main was not merged this round: the PR reads mergeable true and CI is green on 55663d7.",
"The worktree was removed after the push; the remote branch is at 55663d7."
],
"open_questions": [
{
"question": "PR #25178 body sentences for the seat to add (I may not PATCH it).",
"options": [
"Under 'Outside the claim's letter': 'Declared since: 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).'",
"Replace 'One gate is still red': 'packages/spec/scripts/liveness/empty-state-registry.mts carries one row (file: the preference object, property user_id, semantics scope; rationale: an empty User selects which users a delivery toggle covers and grants no access, a user row overrides it, mandatory topics bypass it, cascade closes the set_null widening; evidence: preference-resolver.ts resolveRow). check:empty-state → 0 with it, 1 without it.'",
"Add: 'The boot conversion reports the engine error message (messageOf), not operatorFacingErrorText. The types test:repo roster enumerates that helper's raw-SQL callers, and this path issues no raw SQL (55663d7).'",
"Replace the dogfood line: 'Full battery at 55663d7, run alone under the lock: 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 not attributable to this change: the showcase-single-platform-insert-default-organization beforeAll hook (default 10 s budget for a full showcase boot) and the object-designer-field-reorder publish test (5 s).'"
],
"recommendation": "Add all four as written, so the body matches head 55663d7."
}
],
"out_of_scope_findings": [
"carrier: none ·
docs/qa/platform-checklist/areas/integration-system.json:1668still describes "'*' wildcards + admin-global default" for sys_notification_preference; the user half reads stale. Noted in the PR's Acceptance notes, not filed.","carrier: none ·
docs/adr/0030-notification-platform-convergence.mdP2 row ('admin-global*defaults') is history on a governed surface; left as is, noted, not filed.","carrier: the seat (ruling item 5) ·
sys_notification_delivery.display_titlehas the same raw-id shape; it is the seat's own card, not this PR.","carrier: none ·
packages/qa/dogfood/test/showcase-single-platform-insert-default-organization.dogfood.test.ts:42: its beforeAll boots the full showcase and signs in on vitest's default 10 s hook budget, while sibling showcase files budget 60–120 s for the same boot. It timed out in both full batteries (f7ad7e7, 55663d7) on a loaded box and passed in isolation. Test-budget only, not a product defect; noted, not filed."]
}
objectstack-fleet commentedon Oct 22, 2026
ACCEPT → PR #25178 comment 6336745394 ·
domain:servicesseat 2 ·session_01UnHcTADjeg3VwxF21QHj7W· 2026-10-22T08:52Z · contract review PASS atCONTRACT_REVIEW_TIER6336714107· ready and auto-merge set through the relay; the landing note follows the merge.Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
Landed ·
domain:servicesseat 2 ·session_01UnHcTADjeg3VwxF21QHj7W· 2026-10-22T09:22Ze282f32134completedbyFixesState:
pm:dispatchedis removed in this act.What changed:
sys_notification_preference.user_idis asys_userreference, and an empty User is the admin-wide default.'*'rows are rewritten at boot.Follow-ups filed at landing:
display_title, ruling A item 5.domain:cli.The stale QA-checklist annotation is in the seat's sign-off brief.
Generated by Claude Code