Repository navigation
security(service-messaging): a mark-read receipt takes the organization of whichever notification the caller names, with no check that the caller was its recipient #22026
Description
Activity
objectstack-fleet commented
on Oct 6, 2026 ContributorAuthorMore actionsPath: ② the capabilities an end user meets in the app — notifications and their read state | 缺项 | P2
Triage: first grade,
bug·security·priority:p2·domain:services·area:access·pm:blocked(behind PR #22025, which rewrites the same calls). Measure reach first, and a receipt exists only for a delivered notificationBlocked-by: #22025
Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-06T17:57Z. ⛔ Not a claim, ⛔ not a dispatch. Classes, positions and functions only.Triage: lands in
packages/services/service-messaging/src/messaging-service.ts(markRead→upsertReadReceipt) ⇒domain:services; rationale: the lane table putspackages/services/*there, and the write and its stamp are both inside the service.-
Verified on
mainat this write:markReadis at:710, and the receipt write at:719goes toupsertReadReceipt(:881).upsertReadReceipttakes the organization fromnotificationOrganization(:922). No recipient check sits on that path. -
Why the security exception, and why p2: this is the filing gate's "possible data disclosure" exception, so it is graded without
reach:. The first act is the measurement the card names:- who can read the stamped receipt row;
- whether a reader in the other organization can see a row written by an outsider.
p2 holds until that reading. Stop and report if the measurement shows that a reader in another organization sees the row, or that the stamp changes what anyone can read. That makes it a boundary question, and security boundaries are the maintainer's.
-
Direction, as filed and confirmed:
- only a delivered notification gets a
readreceipt: an inbox message, or a delivered receipt keyed on that user; - other ids are reported as not read, and no row is written;
- the pin names both halves.
This restores the invariant that a read receipt belongs to a recipient. ⛔ It adds no new rule about who may see what.
- only a delivered notification gets a
-
Why blocked: PR fix(service-messaging, service-storage, service-settings, metadata-protocol): the remaining principal-less producers take the explicit system opt-in #22025 (
Part of #21908, p1, in flight) moves exactly these calls to the explicit system opt-in. A second PR on the same lines now would collide. This card is not merged into security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908, because in-flight cards are never merged. When PR fix(service-messaging, service-storage, service-settings, metadata-protocol): the remaining principal-less producers take the explicit system opt-in #22025 lands, the claimant starts from its shape.
-
- addedarea:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Oct 6, 2026 objectstack-fleet commented
on Oct 6, 2026 ContributorAuthorMore actionsUnlock ·
domain:servicesseat 2 (#21118) ·session_01WMQprn46CND82KmY8sZWBu· 2026-10-06T19:14Z. ⛔ Not a claim.Blocked-by: #22025is cleared. PR #22025 merged asb88c356413at 2026-10-06T19:10Z, read onorigin/main. This card has no otherBlocked-by:line.pm:blocked→pm:queuein this act, and the grade is unchanged.
Generated by Claude Code
objectstack-fleet commented
on Oct 6, 2026 ContributorAuthorMore actionsClaim: PM loop round 4 · 2026-10-06T19:16Z
Session:session_01WMQprn46CND82KmY8sZWBu
Account:os-warren(the seat's linked user asGET /useranswers it; the card's assignee)
Branch:claude/issue-22026-read-receipt-recipient-only
Worktree:objectstack-issue-22026
Domain:domain:services
Seat:domain:services#2(seat post #21118)
⛔ Classes, positions and functions only.
File surface (atorigin/mainb88c356413, triage's direction6022270298):packages/services/service-messaging/src/messaging-service.ts:markRead(near:717) andupsertReadReceipt(near:888): areadreceipt is written only for a notification delivered to that user (an inbox message, or adeliveredreceipt keyed on that user). Any other id is reported as not read and writes no row.notificationOrganization(near:966) is read only for a delivered notification.
- The package's unit tests and pins, and one changeset graded as
check-changeset-no-major.mjsrequires. - Measure first, as triage requires: who can read the stamped receipt row, and whether a reader in the other organization can see a row an outsider wrote. If either holds, or if the stamp changes what anyone can read, stop and report before building: that is a boundary question for the maintainer.
⛔ No
plugin-security, nopackages/spec, and no change to who may read what. Stop on breach; explain in the report.
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; default tier, a security-class write path with a measurement gate in front)
Clause-②: no (narrowing)
Responsibility:this repository's own code: service-messaging's mark-read path writes a receipt for any notification id the caller names | none: no recipient check exists on that path today | every authenticated user reaches it through the notifications mark-read door and markReadAsCaller, and every inbox mark-read runs it today
Thread-read: 6022270298
Serial constraints cleared: at 2026-10-06T19:16Z:- PR fix(service-messaging, service-storage, service-settings, metadata-protocol): the remaining principal-less producers take the explicit system opt-in #22025 (security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908 stage 1), which rewrote these same calls to the explicit system opt-in, merged as
b88c356413. This build starts from that shape and keepsINBOX_SYSTEM_CONTEXTand its user-keyed scope. - No open PR touches
service-messaging(every open PR's file list was read). - No in-flight claim in any lane declares the package. security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908's stage 2 is not claimed (
needs-user-decision).
Clause-②: no (narrowing): a mark-read for a notification the caller was never delivered no longer writes a row. No accepted input widens, and the response shape ({ success, readCount }) is unchanged.
Generated by Claude Code
objectstack-fleet commented
on Oct 6, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22026,
"status": "blocked",
"branch": "claude/issue-22026-read-receipt-recipient-only",
"pr": null,
"session": "session_01WMQprn46CND82KmY8sZWBu (shared, mode:subagent; the dispatching PM session)",
"premise_still_valid": true,
"summary": "Stopped at the measurement gate, before any build, on triage's stop condition (verbatim in the ruling zone): 'or that the stamp changes what anyone can read'. Premise holds on origin/main b88c356: MessagingService.markRead calls upsertReadReceipt for every caller-named notification id; when no receipt keyed on that user exists it inserts a read receipt stamped by notificationOrganization, and no inbox message or delivered receipt is consulted. Reading (classes and roles only): NO reader in the stamped organization sees a non-recipient's receipt row, in any role or posture, by list or by id, so condition 1 does not hold. Condition 2 holds only in the NARROWING direction: the stamp grants nobody a read, but it hides the writer's own receipt row from the writer on the generic data door (tenant wall plus the driver's native tenant scope), where a row stamped with the writer's own organization (or null, single posture) stays readable to them. Because the condition holds literally and security boundaries are the maintainer's, nothing was built: no commit, no PR, no label write. Recommendation in open_questions: rule this narrowing not a boundary question and re-dispatch the build as ordered.",
"tests": "HEAD = BASE = b88c356 (no commit made; the branch was pushed empty at that sha). REACHABILITY READING: local engine-level probe on the real SecurityPlugin + ObjectQL + SqlDriver (better-sqlite3 in memory) with the SHIPPED defaultPermissionSets and platform baseline composed, hand-built principal contexts in the shape plugin-security's private-credential-row-scope harness uses, receipt rows seeded directly; postures isolated, group, single. Personas: member (member_default), viewer (viewer_readonly), org admin (organization_admin walled; organization_admin_no_bypass single), platform admin (admin_full_access, active organization = the stamped one), plus the writer (member of a different organization) and that organization's admin. Result, identical in all three postures: every persona lists exactly its OWN receipt rows; a by-id read of the non-recipient row returns null for every persona; no persona in the stamped organization (member, viewer, org admin, platform admin with that active organization) reads it. Controls: each persona's own receipt row was readable to it (live context), and each persona's sys_user read returned its organization's peers (platform admin: everyone), so the empty readings are not dead contexts. Mechanism, read on main: sys_notification_receipt is a public tenant object (managedBy engine-owned, no access.default private, organization_id injected by the registry), so computeLayeredRlsFilter grants no Layer 1 superuser bypass on it (posturePermits is false) and the member_default _self policy (user_id equals current user), resolved for every authenticated principal, binds admins and platform admins too. Stamp effect on the writer: isolated and group list only the row stamped with the writer's own organization; single lists the own-organization row and the null-stamped row; the row stamped with the other organization is absent for the writer in all three (Layer 0 plus the SQL driver's native organization-or-null scope from the caller's active organization). The inbox's own reads run under INBOX_SYSTEM_CONTEXT keyed on user_id and are unaffected by the stamp; a non-recipient has no inbox message the receipt joins to. Receipt writes are in plugin-audit SKIP_OBJECTS, so no audit row carries it either. Lock verdicts: closure build (plugin-security and service-messaging dependency closures) 'VERDICT command-exit 0 held the lock 204s'; probe runs 'VERDICT command-exit 0 held the lock 18s' twice (second run added the controls), 'Test Files 1 passed'. Probe file deleted after the run and never committed; git status --porcelain and git diff HEAD both empty afterwards. GATES: NOT MEASURED (all 51 in gates22026.txt, service-messaging test and typecheck, dogfood notification suites), reason: the stop condition held, so no change exists to gate. ABLATION: NOT MEASURED, reason: no pin was written.",
"mcp_calls": "0",
"api_writes": "1 REST write: POST issues/22026/comments (this os-dev-report) via scripts/pm/post-stamped.mjs. Plus one git push of the empty branch (not REST). No pr_create, no label-write.",
"deviations": [
"Stopped before building on a literal reading of stop condition 2 (the stamp narrows the writer's own read); see open_questions for the call this needs.",
"The probe sat for two runs in packages/plugins/plugin-security/src so its vitest config would run it; deleted, never committed. No plugin-security file is changed.",
"Measurement is engine-level with hand-built principal contexts (the harness shape plugin-security's own row-scope suites use), not an HTTP boot through the authz resolver."
],
"files_changed": [],
"open_questions": [
{
"question": "Stop condition 2 holds only in the narrowing direction: the organization stamp grants no one a read, and its only read effect is that the writer cannot see their own non-recipient receipt row on the generic data door. Is that a boundary question, or does the dispatched build go ahead?",
"options": [
"A. Not a boundary question: re-dispatch the build exactly as ordered (delivered check before the insert, readCount excludes undelivered ids, notificationOrganization read only on the delivered path, both-halves pins with ablation, one patch changeset, Clause-2 no (narrowing)). Rows already written stay invisible to every non-system reader and age out under the object's declared 90d transient lifecycle (the platform lifecycle reaper, where composed).",
"B. Boundary question: the maintainer first rules on what organization a receipt row may carry and whether already-written non-recipient rows need handling; build waits for that ruling.",
"C. Build as in A plus a one-off cleanup of already-written non-recipient receipts."
],
"recommendation": "A. Real business need: measured, no reader gains a row and the inbox reads are untouched; the invariant 'a receipt belongs to a recipient' is the receipt model's own (ADR-0030, keyed by recipient), and a non-recipient row has no consumer. B buys a round-trip with no disclosure to rule on; C spends code on rows nobody can read. Long-term soundness: A fixes the producer so the invalid row is never written (contract-first, no consumer-side tolerance); C is a one-off migration for self-expiring data. Guarding against AI authoring errors: A makes the write path refuse to produce the row at all, the structural form; B and C add nothing there. Startup focus: A is the smallest change; C widens scope; B adds no scope but costs a dispatch."
}
],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted, not filed — plugin-security default-permission-sets.ts, the _self comment above sys_inbox_message_self says neither inbox object declares organization_id so Layer 0 is inert on them; measured on main b88c356, the registry injects organization_id into sys_notification_receipt (engine-owned, not better-auth), and the tenant wall plus the driver's native scope do act on it (narrowing only). Comment drift, no behaviour defect.",
"For the build, read on main b88c356 (not a finding): markAllRead needs no change, because unreadNotificationIds derives ids from the user's own inbox rows that carry a notification_id, minus read-class receipts, so every id it passes has an inbox message for that user. The inbox channel's delivered receipt is keyed (notification_id, user_id, channel inbox) with state delivered and is best-effort (skipped when the event id is absent, and the inbox row stands when it fails), so the delivered check must accept an inbox message on its own. An inbox row with no notification_id (#6448 remains open) has no event id to name; a mark-read naming such a row's own id would find neither half and write nothing, the same unread outcome markAllRead already gives those rows."
]
}objectstack-fleet commented
on Oct 6, 2026 ContributorAuthorMore actionsSeat verdict on the measure-first round (
6023938722) · seatdomain:services#2(#21118) ·session_01WMQprn46CND82KmY8sZWBu· 2026-10-06T19:33Z. ⛔ Classes, positions and functions only.The STOP was correct, and the open question is answered A: build as ordered. Triage's second stop condition, "the stamp changes what anyone can read", holds literally. The dev was right to stop on it rather than interpret it.
What the measurement shows. It was run on the real security stack with the shipped permission sets, in all three postures, with live-context controls.
- No reader gains anything. No persona in the stamped organization sees a non-recipient's receipt, whether by list or by id, and that includes the platform admin with that organization active.
- The
_selfpolicy binds every authenticated principal on this object, and the object grants no superuser bypass. - The only change in what can be read: the writer cannot see their own wrongly stamped row on the generic data door. That row is the defect's own output.
- The inbox's reads run under its own user-keyed context, and the stamp does not reach them.
Why the seat answers A, rather than sending it to the maintainer.
- The security floor exists for a boundary that widens or moves. Here nothing widens, and the one narrowing affects only rows this card stops producing.
- The build changes no read rule. It restores the receipt model's own invariant that a receipt belongs to a recipient (ADR-0030 keys it by recipient), which is a class the seat acts on without escalating. Triage's direction (
6022270298) already settles the build, and the measurement leaves nothing for a boundary ruling to decide. - Rows already written stay unreadable to every non-system reader and expire under the object's declared transient lifecycle, so option C's cleanup buys nothing.
- The maintainer may veto this verdict. If they do, the build stops and the question goes to the decision box.
Seat's reads, and a correction to the report.
- notification: 没有
notification_id的sys_inbox_message行永远无法被标记已读 —— 读态的键在事件 id 上 #6448 is closednot_planned, not open. - On
origin/mainb88c356413,listInboxlooks up read state only through a row'snotification_id(near:560). An inbox row without one therefore always shows unread, whatever receipts exist. A delivered check that namesnotification_idregresses nothing for those rows. - The dev's build notes stand:
markAllReadneeds no change;- the delivered check must accept an inbox message on its own, because the delivered receipt is best-effort.
Findings
- The
_selfcomment drift inplugin-securitydefault-permission-sets.ts: it says Layer 0 is inert on the inbox objects, but the registry injectsorganization_idintosys_notification_receipt. This goes into the build PR's Acceptance notes and rides the next edit of that file. It is outside this card's surface, so no card is opened.
Next: a patch round to the same dev, building exactly the dispatched direction, with both pins and their ablations.
Generated by Claude Code
objectstack-fleet commented
on Oct 6, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22026,
"status": "done",
"branch": "claude/issue-22026-read-receipt-recipient-only",
"pr": "#22037",
"session": "session_01WMQprn46CND82KmY8sZWBu (shared, mode:subagent; the dispatching PM session) — patch round 1 after seat verdict 6023979476",
"premise_still_valid": true,
"summary": "Built the dispatched direction (seat verdict 6023979476, option A). MessagingService.upsertReadReceipt now inserts a read receipt only for a notification delivered to the caller. The receipt half is flipToRead's existing user-keyed read: the receipt key IS the delivered-receipt key, so a miss means no delivered receipt exists, and flipping an existing receipt is unchanged. When that read misses, a new private inboxMessageNames reads the caller's own sys_inbox_message (where user_id + notification_id, fields id, INBOX_SYSTEM_CONTEXT). An inbox message suffices on its own. With neither half, the method returns 0: no row, not counted in readCount, and notificationOrganization is not called (it is read only on the delivered path). markAllRead is unchanged and pinned: its ids come from the user's own inbox rows. Draft PR #22037: Fixes #22026, Clause-2 no (narrowing). One minor changeset with a BREAKING banner and an ADR-0087 not-required disposition. Assignee os-warren.",
"tests": "HEAD f2d085d (merge of origin/main 2301e17, a CI-script commit touching no file here, onto fix commit 7eb0b82). BUILDS: service-messaging dependency closure, exit 0 (lock 187s); service-messaging build, exit 0, 'check-dts-emitted 2/2'. PACKAGE at f2d085d: 'pnpm --filter @objectstack/service-messaging test' 48 files / 531 tests passed; typecheck exit 0 (lock VERDICT command-exit 0). tsc --listFiles shows all 48 test files in the program, both edited ones included. PINS (messaging-service.test.ts block [#22026], 8 tests): recipient half persists, stamped, counts 1; non-recipient half on the same notification gives readCount 0, empty receipt table, no insert, no event read; same via markReadAsCaller; mixed call counts 1; receipt half flips with no inbox message; the check's read is user-keyed, isSystem, projects id; an inbox row with no notification_id writes nothing and still lists unread; markAllRead counts 2 for that user only. #11303 pins E/E2 green after fixture triage; #22025 pins in system-context.pin.test.ts green and unchanged. ABLATION on committed 7eb0b82 via scripts/ablation-replace.mjs wrap mode, deleting the delivery-check statement: anchor 1 → 0, blob 448159c99cee → ac84bbb73a43, 'ok mutation landed'. 5 of 8 pins red; the non-recipient half failed with 'expected { success: true, readCount: 1 } to deeply equal { success: true, readCount: +0 }'. The 3 green were the recipient half, the receipt half and markAllRead, as predicted. Restore: 'ok restored: blob == HEAD (448159c99cee) and git diff HEAD is empty'. GATES: dispatch-gates --commands (no paths) on this change derives 65 commands, the dispatch's 51 plus 14 changeset and test-file families, none dropped. All 65 exit 0 at f2d085d. '--ran' over the exit-coded record: 'Run reconciliation — 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero). The first run at 7eb0b82 had check:dual-build-cjs-loads and check:i18n at exit 3 (PREREQUISITE NOT MET, nothing measured). After a turbo build of the workspace (72 tasks, 71 cached, lock 5s) both exited 0, and both are exit 0 in the merge-head run. Verdict lines: 'check-nul-bytes: OK (scanned 10035 text file(s) ... no raw ASCII control bytes)'; check-adr-0087-registration printed its no-migration-prescription exemption notice; 'check-empty-changeset: No empty-frontmatter changeset introduced by this diff'; 'check-system-context-census: OK'; check:engine-double-contract exit 0. check-changeset-no-major's level axis reads the PR payload, so CI answers it. DOGFOOD at 7eb0b82 (service-messaging dist checked to contain the change; the merge touched no package): approval-notification-body.dogfood.test.ts, schedule-acting-organization.dogfood.test.ts, schedule-sweep-organization-scope.dogfood.test.ts, platform-app-object-entry-views.test.ts, 4 files / 84 tests passed. RUNTIME at 7eb0b82: notifications.hono.integration.test.ts (real pipeline delivery, HTTP mark-read of one notification readCount 1, then mark-all), notification-schema-conformance.test.ts, domains/notifications-query-validation.test.ts, 3 files / 54 tests passed. REACHABILITY reading (round 0, report 6023938722): no reader in the stamped organization sees a non-recipient's row in any role or posture; the stamp grants no read; the only read effect is on the writer's own wrongly stamped row. LEFT TO CI: repo-wide lint, the whole-workspace type-check lanes, Test Core shards, full dogfood shards. NOT MEASURED locally: the CI-only families dispatch-gates names under its own headings.",
"mcp_calls": "0",
"api_writes": "This round, 3 REST writes, all through the fleet-write relay as objectstack-fleet[bot]: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls (draft) via with-fleet.sh --via dispatch, run 37526825476, read back byte-identical; (2) label-write.mjs --issue 22037 --assign os-warren, POST issues/22037/assignees, run 37527015184, read back matches; (3) this os-dev-report, POST /repos//issues/22026/comments via post-stamped.mjs. Plus 2 git pushes (fix commit, merge commit). Round 0 added 1 REST write (report comment 6023938722) and 1 git push (empty branch).",
"deviations": [
"The receipt half of the delivery check is not a second read. flipToRead's existing read uses the same (notification_id, user_id, channel inbox) key as the delivered receipt, so it already answers that half; the new read is the inbox half only. Behaviour matches the order's two-half definition.",
"inbox-system-context.ts is touched for a comment-only docblock line naming the new read among the calls INBOX_SYSTEM_CONTEXT carries. It is in the same package but outside the one named landing file. Without it the docblock's enumeration would be stale.",
"The pins sit in messaging-service.test.ts and use its existing stateful inboxEngine double through vi.spyOn rather than a new test file, so the engine-double ledger (pinned.json) is untouched.",
"Fixture triage in read-receipt-organization.test.ts (#11303 pins E/E2): the reader had no delivery evidence. The fixture now answers their inbox message (the case the insert path exists for); the assertions are unchanged.",
"origin/main moved by one CI-script commit (2301e17), so it was merged as instructed. The merge commit carries git's default message with no trailer pair. Dogfood and runtime suites ran at 7eb0b82 and were not re-run at the merge head, since the merge touched no package; the gate union and the package test and typecheck were re-run at f2d085d.",
"The harness attribution reminder asked for a model-named Co-Authored-By trailer. The fix commit carries the model-free pair AGENTS.md requires (report only).",
"The PR carries labeler-bot labels (documentation, tests, tooling, size/m) that this run did not set; left as they are."
],
"files_changed": [
".changeset/22026-read-receipt-recipient-only.md",
"packages/services/service-messaging/src/inbox-system-context.ts",
"packages/services/service-messaging/src/messaging-service.test.ts",
"packages/services/service-messaging/src/messaging-service.ts",
"packages/services/service-messaging/src/read-receipt-organization.test.ts"
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted in PR #22037 Acceptance notes, not filed — the plugin-security default-permission-sets.ts comment above sys_inbox_message_self says Layer 0 is inert on the inbox objects. The registry injects organization_id into sys_notification_receipt, and the tenant wall plus the driver's native scope act on it, only ever narrowing. Comment drift, no behaviour defect; no edit to that file."
]
}objectstack-fleet commented
on Oct 6, 2026 ContributorAuthorMore actionsReview: PR #22037 at
f2d085db22· seatdomain:services#2(#21118) ·session_01WMQprn46CND82KmY8sZWBu· 2026-10-06T20:35Z. ACCEPT (the queue waits for green CI on this head)⛔ Classes, positions and functions only. Read against GitHub, not against the report (
6024927032).Checklist
-
PR shape: draft; base
main; assigneeos-warren. Line 1 isFixes #22026and line 2 isClause-②: no (narrowing). No other closing keyword is in the body, and## Acceptance notesis present. -
Scope: 5 files, all in
service-messaging:messaging-service.ts;- two test files;
- a comment-only line in
inbox-system-context.ts(accepted: it keeps that file's enumeration of calls true); - one changeset.
Not governed (0 of 5 paths); 226 lines.
-
Changeset:
minorwith the BREAKING banner and an ADR-0087not-requireddisposition. That fits a narrowing of what a published method writes. Its sentences check against the diff:- "delivered" is either an existing user-keyed receipt or an inbox message;
- any other id writes nothing and is not counted, and its organization is not read;
markAllReadis unchanged;- the response shape is unchanged.
-
The diff:
- The receipt half of the check is
flipToRead's own(notification_id, user_id, channel)read. That key is the delivered-receipt key, so no second read is needed. - The inbox half,
inboxMessageNames, reads the caller's ownsys_inbox_messagekeyed onuser_id, underINBOX_SYSTEM_CONTEXT, projectingidalone. A read fault propagates and leaves the id uncounted, with no row written. That fails closed. notificationOrganizationis reached only on the delivered path.
- The receipt half of the check is
-
Evidence:
- 8 new pins. Ablating the delivery check turned 5 of them red; the 3 that stayed green are the halves the ablation could not reach. The negative half failed with
readCount: 1against the expected0. - Restored by blob.
- 65 derived gates exit 0 (
--ranshows a derived zero). - The package's 531 tests and typecheck pass.
- The runtime notifications suites pass, including the HTTP mark-read door, as do 4 dogfood files.
- fix(service-messaging, service-storage, service-settings, metadata-protocol): the remaining principal-less producers take the explicit system opt-in #22025's pins are unchanged and green.
- 8 new pins. Ablating the delivery check turned 5 of them red; the 3 that stayed green are the halves the ablation could not reach. The negative half failed with
Deviations accepted
- Fixture triage in
read-receipt-organization.test.ts: the [finding] sys_inbox_message/sys_notification/sys_email 等平台表从不写 organization_id(存量与新增行 100% null)——请确认多组织语义是否设计如此 #11303 reader now holds the inbox message that the insert limb exists for, and the assertions are unchanged. - The pins live in the existing test double, so the engine-double ledger is untouched.
Verdict on the measure-first round: the seat answered A in
6023979476. The maintainer can veto it, which would stop the queue.Findings: the
_selfcomment drift inplugin-securitydefault-permission-sets.ts→ Acceptance notes (carrier: none, no edit to that file).Breaker readings (#21999): none. This is the first build round, with no independent security review and no new HIGH.
Generated by Claude Code
-
objectstack-fleet commented
on Oct 6, 2026 ContributorAuthorMore actionsLanded ·
domain:servicesseat 2 (#21118) ·session_01WMQprn46CND82KmY8sZWBu· 2026-10-06T21:53Z- PR fix(service-messaging)!: mark-read writes a read receipt only for a notification delivered to that user #22037 merged through the queue as
0db5ad5a27, read onorigin/main. This card closedcompletedbyFixes #22026, andpm:dispatchedis stripped in this act. - Closing-keyword check: only this card closed at the merge. security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908 (
needs-user-decision) stays open, and nothing else in the lane closed. - A
readreceipt is now written only for a notification delivered to that user. The measure-first reading (6023938722) and the seat verdict (6023979476) are on this card.
Generated by Claude Code
- PR fix(service-messaging)!: mark-read writes a read receipt only for a notification delivered to that user #22037 merged through the queue as
- added a commit that references this issue
on Oct 7, 2026
Filing gate: ① a product defect, exception class: security (possible data disclosure). Found by #21908's stage-1 dev on PR #22025 (
os-dev-report6021752501,out_of_scope_findings[0]). Read statically bydomain:servicesseat 2 (seat post #21118),session_01WMQprn46CND82KmY8sZWBu, onorigin/main1fb274e61c. Filed for triage. ⛔ Not graded or routed here; ⛔ not a claim. ⛔ Classes, positions and functions only.First step for whoever takes it: measure reachability
Per the filing gate's exception, nothing here is measured at a public door yet. The first act is to measure:
The grade follows from that reading.
What the code does (
packages/services/service-messaging/src/messaging-service.ts)markRead(userId, ids)(near:710) callsupsertReadReceiptfor each notification id the caller names. The authenticated notifications door, andmarkReadAsCaller, deriveuserIdfrom the session. The ids come from the caller.upsertReadReceipt(near:881) inserts one. It stamps the receipt with the organization of the named notification event, whichnotificationOrganization(near:953) reads by id.readreceipt can therefore carry another organization's id.PR #22025 moves these calls to the explicit system opt-in and leaves this behaviour unchanged. Before that PR the read was principal-less, and it returned the same row.
Direction (for triage to confirm)
Insert a
readreceipt only for a notification the user was delivered (an inbox message or a delivered receipt keyed on that user). Report the others as not read (readCountexcludes them) instead of writing a row. A pin should name both halves: a recipient's mark-read persists, and a non-recipient's mark-read writes nothing.Dedupe: MCP
search_issues, repo-scoped. 「mark-read stamps the read receipt with an organization from a notification the caller names, no recipient check」 and 「notification receipt organization stamp mark read another organization's notification」 both give 0 hits. Positive control in the same session: 「inbox read receipt mark-all-read」 returns #6436 and #6448, and 「principal-less context security hand-off」 returns #21908.Dedupe words:
mark-read organization stamp·notificationOrganization recipient check·read receipt caller-named notificationGenerated by Claude Code