Commit 3987a48
OS-DEV-REPORT
=============
status: done
branch: claude/issue-7344-member-inbox-grants
base: origin/main @ 9051802 (fetched fresh; measured against origin/main, not a stale tree)
issue: #7344
ruling executed: comment 5248466727 (2026-08-11), Option A
premise_still_valid: YES — re-verified on origin/main @ 9051802. `member_default`
(packages/plugins/plugin-security/src/objects/default-permission-sets.ts:314) named the
better-auth identity tables plus `sys_user_preference` (:363) and nothing else, while
packages/platform-objects/src/apps/account.app.ts:88-99 declares the Notifications nav entry
with `requiresObject: 'sys_inbox_message'` and the app declares no `requiredPermissions`.
The mismatch the card describes is exactly what the code showed.
STEP 1 — PRODUCER MEASUREMENT (the gate): PRODUCER EXISTS ⇒ Option A
---------------------------------------------------------------------
A producer writes member-visible inbox messages today. Approval notifications are the
writer, as the ruling expected. Full chain, each hop cited:
1. INSERT PATH (the rows themselves)
- packages/services/service-messaging/src/inbox-channel.ts:112
`const created = await data.insert(objectName, row)` where `objectName` defaults to
`sys_inbox_message` (:13, :47) and `row.user_id = delivery.recipient` (:95, :99).
The row is keyed by the RECIPIENT's own user id — precisely the rows a plain member
would see under a `user_id == current_user.id` scope.
- packages/services/service-messaging/src/inbox-channel.ts:64
`await data.insert(receiptObject, { ... user_id: r.userId, channel: 'inbox',
state: 'delivered' ... })` → `sys_notification_receipt` (:16). This is why the grant
needs both objects: read-state lives on the receipt, not the inbox row (ADR-0030,
inbox-channel.ts:119-120).
2. THE CHANNEL IS ALWAYS-ON, NOT OPT-IN
- packages/services/service-messaging/src/messaging-service-plugin.ts:98
`registerInbox: true` is the constructor DEFAULT.
- packages/services/service-messaging/src/messaging-service-plugin.ts:126-127
`if (this.options.registerInbox) service.registerChannel(createInboxChannel({ getData }))`
3. THE DEFAULT CHANNEL IS `inbox`, SO ANY UNQUALIFIED emit() MATERIALIZES A ROW
- packages/services/service-messaging/src/messaging-service.ts:620
`const channels = input.channels?.length ? input.channels : ['inbox'];`
- The preference filter at :621 only drops (recipient × channel) pairs the user MUTED
and is fail-open on error (:617-618) — an ordinary member with no preference row is
not filtered out.
4. THE PRODUCER: THE APPROVALS PIPELINE
- packages/plugins/plugin-approvals/src/approval-service.ts:609-640 — `notify()` calls
`this.messaging.emit({ severity: 'info', ...input, payload, audience })` at :632 with
NO `channels` key ⇒ falls to the `['inbox']` default above.
- PRODUCTION wiring (not just tests): packages/plugins/plugin-approvals/src/approvals-plugin.ts:188-191
`const messaging = ctx.getService<ApprovalMessagingSurface>('messaging');`
`if (messaging && typeof messaging.emit === 'function') this.service.attachMessaging(messaging);`
5. REACHABILITY FOR A MEMBER PERSONA (what user action triggers it)
Member-as-submitter (the strongest case — a plain member with no approver role):
a plain member submits a record that enters an approval flow, so the request's
`submitter_id` is that member's user id. An approver then clicks "Request info" (or
comments, or the request is returned):
requestInfo() → approval-service.ts:2872-2875
`notify({ topic: 'approval.request_info', audience: [String(raw.submitter_id)] })`
→ emit() with no `channels` → default `['inbox']` (messaging-service.ts:620)
→ preference filter passes → inbox channel `send()`
→ INSERT sys_inbox_message { user_id: <the member's OWN id> } (inbox-channel.ts:112)
+ INSERT sys_notification_receipt { user_id, channel:'inbox', state:'delivered' } (:64)
Same shape via `approval.returned` (:2353-2355, :2395-2397) and `approval.comment`
(:2915-2918, audience = the other side of the thread).
Member-as-approver:
an approval routes to the member; the submitter clicks "Remind" →
`approval.reminder`, `audience: [approver]` (:2702-2705). Also `approval.reassigned`
→ the new approver (:2643-2645), `approval.ooo_substituted` → the covering approver
(:1622-1624), `approval.escalated` (:3273-3275), `approval.sla_breached` (:3292-3295).
6. THE emit()→ROW HOP IS COVERED BY AN EXECUTED TEST, not just read statically
- packages/services/service-messaging/src/notification-schema-conformance.test.ts:142-158
registers the REAL `createInboxChannel` against an in-memory engine and calls
`service.emit({ topic, audience, payload })` with NO `channels`, then reads the rows
back through `listInbox`. That pins the default-channel → inbox-row materialization.
ZERO-HIT DISCIPLINE: one grep did return a suspicious zero — `attachMessaging` appeared
only in `*.test.ts` on the first pass, which would have argued "wired in tests only, no
real producer". I falsified it before concluding anything: a count probe returned 11 total
occurrences vs 3 non-test, which surfaced the production call site at approvals-plugin.ts:190.
No absence in this report rests on an unfalsified zero-hit.
⇒ The feature is NOT empty. Option A implemented; NOT flipped to Option C.
STEP 2 — WHAT CHANGED
---------------------
1. packages/plugins/plugin-security/src/objects/default-permission-sets.ts
`member_default.objects` (after the `sys_user_preference` precedent row):
sys_inbox_message: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }
sys_notification_receipt: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }
`member_default.rowLevelSecurity` (after `sys_oauth_application_self`):
{ name: 'sys_inbox_message_self', object: 'sys_inbox_message', operation: 'select', using: 'user_id == current_user.id' }
{ name: 'sys_notification_receipt_self', object: 'sys_notification_receipt', operation: 'select', using: 'user_id == current_user.id' }
Shape rationale, measured rather than assumed:
- `_self` policies are REQUIRED, not decorative. Neither object declares an
`organization_id` field (inbox-message.object.ts:50-105, notification-receipt.object.ts —
grep for `organization_id` returns no field declaration on either), so the ADR-0095 D1
Layer 0 tenant wall is inert on them, exactly as it is on `sys_oauth_application`
(default-permission-sets.ts:501-504). Without these policies the new read bit would have
been org-wide — the one outcome the ruling's "RLS-scoped to the caller" forbids.
- `operation: 'select'`, not `'all'`. The `sys_user_preference_self` precedent uses `'all'`
because that grant is read+write; ours is read-only, so it follows the read-only
precedent in the same file (`sys_oauth_access_token_self` :486, `sys_team_member_self` :456).
- READ ONLY, no create/edit/delete. The ruling says "read grants", and the measurement
agrees that nothing in the flow needs more: rows are written by the inbox channel
(inbox-channel.ts:112/:64) through the messaging service's own engine handle, and
mark-read is served by POST /api/v1/notifications/read → `inbox.markRead(userId, ids)`
(packages/runtime/src/domains/notifications.ts:18-19, :198, :205), NOT the generic data
API. So no edit bit on `sys_notification_receipt` is needed to make the inbox usable.
`allowDelete`/`allowExport` stay false, keeping the set bindable to the `everyone`
anchor (ADR-0090 D5 / #2753).
- ⛔ `sys_activity` NOT added, per the ruling. Pinned as an explicit negative test.
- #5491 posture respected: no `'*'` key, no sentinel, no glob — two NAMED objects.
2. packages/plugins/plugin-security/src/objects/rbac-objects.test.ts:99
The existing sorted exact-list `toEqual` pin of `member_default`'s RLS policy names had to
learn the two new names, or it fails. Updated (+2 entries, in sort order). Flagging this
because it is a deliberate pin, not incidental churn — it is the gate that makes any
future silent addition to this set visible.
3. packages/plugins/plugin-security/src/member-default-explicit-allow.test.ts
New `[#7344]` describe block, 10 cases, mirroring the `sys_user_preference` precedent
tests in the same file:
- member CAN read own sys_inbox_message / sys_notification_receipt (object bit)
- the grants are read-only (insert/update/delete all denied) on both objects
- CANNOT read another user's rows: the real `RLSCompiler` compiles member_default's
select policies for each object against a member context and must yield exactly
`{ user_id: 'u_member' }` — a positive equality, asserted to be neither `null` nor
`RLS_DENY_FILTER`, and the policy set for the object is asserted to be exactly
`[<object>_self]`
- an unidentified caller fails CLOSED (`RLS_DENY_FILTER`), not open
- sys_activity remains DENIED on all four axes and has no `_self` policy (the denial
idiom the repo already uses, applied as the ruling's explicit negative)
- the additions stay anchor-safe (no allowDelete/allowExport/viewAllRecords/
modifyAllRecords) and no wildcard crept in alongside them
4. .changeset/member-default-personal-inbox-read.md — patch for @objectstack/plugin-security.
TESTS + TYPECHECK (real output)
-------------------------------
Deps built first (`turbo run build --filter=@objectstack/plugin-security^...`, 17 tasks
successful) — without it vitest fails on `Cannot find package '@objectstack/spec/security'`.
$ cd packages/plugins/plugin-security && npx vitest run src/member-default-explicit-allow.test.ts src/objects/rbac-objects.test.ts
Test Files 2 passed (2)
Tests 53 passed (53)
Duration 3.11s
$ cd packages/plugins/plugin-security && npx vitest run # full package suite
Test Files 45 passed (45)
Tests 941 passed (941)
Duration 19.09s
$ cd packages/plugins/plugin-security && npx tsc --noEmit
=== TYPECHECK EXIT: 0 === # no output, clean
Broader security/RLS surfaces that reference `member_default`, run to catch cross-package pins:
$ cd packages/spec && npx vitest run src/security/explain.test.ts src/system/book.test.ts
Test Files 2 passed (2)
Tests 53 passed (53)
$ cd packages/rest && npx vitest run src/security-routes.test.ts
Test Files 1 passed (1)
Tests 8 passed (8)
NOT run: packages/qa/dogfood (`showcase-default-profile`, `showcase-d7-default-profile`,
`rls-multitenant`, …). They boot a real stack and were out of reach for a sensible cycle time
here. Read statically instead: they use `GET /data/sys_user_preference` as the probe for "is
member_default the governing fallback" and assert 200 under it — adding two unrelated objects
cannot change that verdict. Recording the gap rather than implying coverage I did not run.
CHANGESET
---------
Added: .changeset/member-default-personal-inbox-read.md — `"@objectstack/plugin-security": patch`
(package name read from packages/plugins/plugin-security/package.json:2). This changes shipped
authorization behavior, so it is a required changeset, not an optional one. It carries the
before/after grant table, the verbatim 403 the card measured, and the explicit statement that
this is not a #5491 rollback. content/docs/releases/ NOT touched.
OUT-OF-SCOPE FINDINGS
---------------------
1. `sys_activity` still 403s for a member — correct per the ruling, and it leaves NO nav dead
end: `grep sys_activity packages/platform-objects/src/apps/account.app.ts` returns nothing,
so the `GET /api/v1/data/sys_activity` in the card's evidence comes from a console component
(objectui), not from an Account app nav entry. The ruling's "separate question if it ever
matters" stands, and this PR does not create a new dangling entry by excluding it.
2. #7266 is NOT fixed by this and still matters. This removes the 403; the bell's
"View all notifications" still routes a non-admin to `/apps/setup/sys_inbox_message?view=mine`
(the wrong app). The card predicted exactly this compounding — after this PR a non-admin
lands in the right permission state in the wrong app.
3. Mark-read is safe under read-only grants TODAY because it is served by
/api/v1/notifications (runtime/src/domains/notifications.ts:198/:205), not the data API.
If that ever moves to the generic data API, `sys_notification_receipt` will need an edit
bit and a `check`-clause policy. Noted, deliberately not pre-granted — the ruling says read.
4. `sys_inbox_message` declares a `mine` list view already filtered to
`{current_user_id}` (inbox-message.object.ts:36-47), which is what the nav entry points at.
It now has a matching server-side grant + RLS, so the view is no longer a client-side
filter over an object the caller cannot read at all.
OPEN QUESTIONS
--------------
None blocking. One for the PM to relay if the maintainer wants it on record: this session DID
have read-only GitHub API access (issue body + all 5 comments were read directly, which is how
the ruling text was verified verbatim rather than trusted from the dispatch brief). Write
access was not attempted, per the E34 transport constraint. The E34 note may be worth
narrowing from "no GitHub API tooling" to "no GitHub WRITE tooling" for future cloud dispatches.
Claude-Session: https://claude.ai/code/session_017Prs32y86EcsRGzfdJJK1v
Co-authored-by: Claude <noreply@anthropic.com>
1 parent d17a222 commit 3987a48
4 files changed
Lines changed: 178 additions & 0 deletions
File tree
- .changeset
- packages/plugins/plugin-security/src
- objects
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
Lines changed: 80 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| 30 | + | |
30 | 31 | | |
31 | 32 | | |
32 | 33 | | |
| |||
151 | 152 | | |
152 | 153 | | |
153 | 154 | | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
154 | 234 | | |
155 | 235 | | |
156 | 236 | | |
| |||
Lines changed: 43 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
361 | 361 | | |
362 | 362 | | |
363 | 363 | | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
364 | 388 | | |
365 | 389 | | |
366 | 390 | | |
| |||
508 | 532 | | |
509 | 533 | | |
510 | 534 | | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
511 | 554 | | |
512 | 555 | | |
513 | 556 | | |
| |||
Lines changed: 5 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
102 | 102 | | |
103 | 103 | | |
104 | 104 | | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
105 | 110 | | |
106 | 111 | | |
107 | 112 | | |
| |||
0 commit comments