Skip to content

fix(plugin-audit): a mention notification reaches a mentioned user only when they may read the comment it previews - #26497

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-26453-mention-gate
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-26453-mention-gate

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #26453

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:@提及通知现在要过两道读权限:被提及者既要能读这条评论所在的记录,也要能读这条评论本身。读不到评论的人收不到这条通知。
  • 为什么改:通知里带着评论正文的摘要。能读记录不等于能读评论,因为评论对象有自己的对象权限、租户墙和行级范围。铃铛里的这份副本不能送到评论区本身不给看的人手里。这是安全类卡,细节按维护者裁决不公开。
  • 风险与回滚:如果某个应用给成员的权限集没有授予评论读权限,这些成员以后收不到 @提及通知,这是本修复的预期效果,changeset 里写了管理员该怎么调整。回滚就是 revert 本 PR:只动 @objectstack/plugin-audit 一个包,没有数据迁移,也没有 spec 变更。
  • 先看:① packages/plugins/plugin-audit/src/mention-recipient-gate.ts 里逐收件人判定循环新增的评论读取那一行(readableIn(..., COMMENT_OBJECT, commentId));② mention-recipient-gate.integration.test.ts 里新增的 DAVE 用例和 sys_comment 读权限替身;③ .changeset/26453-mention-recipient-reads-comment.md。
  • 两分钟验证:先构建依赖闭包 pnpm --filter '@objectstack/plugin-audit^...' build,再跑 pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2 src/mention-recipient-gate.integration.test.ts,应该看到 15/15 通过。
  • 需要你做的:无,席位落地。

What changes

  • mention-recipient-gate.ts: the gate used to decide each mentioned user on one read, the record the comment is on. It now also runs that user's own read of the comment, by id, through resolveReadableParentIds, the same evaluator the comment-reaction create door asks of a comment. The read runs in the user's resolved envelope, with their organization claim vetted exactly as before. When that read does not return the comment, the user is withheld and logged with the new reason cannot_read_thread. A comment with no id withholds every candidate (no_comment_id). The record read stays in place and runs first, for two reasons: the deep link opens the record, and an engine with no middleware seam has no thread read gate to fold the record question into the comment read. The module header is rewritten so that what it claims is true. The old header said a thread is readable exactly when its record is, and that is wrong, because the comment object carries its own grant.
  • comment-access-hooks.ts: COMMENT_OBJECT is exported from this internal module so the gate names the object from one source. The package index is unchanged.
  • audit-writers.ts, audit-plugin.ts: the doc comments now describe the two-read rule. No code changes in either file.
  • Changeset: patch on @objectstack/plugin-audit.

Tests

mention-recipient-gate.integration.test.ts runs a real kernel, ObjectQL, SQLite, AuditPlugin (including its comment read gate) and the real grant resolver.

  • New stand-in: plugin-security's object-level read grant on sys_comment. It is keyed on a position assigned through sys_user_position, which the real resolver reads, and it refuses the way the real layer does: a throw carrying PERMISSION_DENIED / 403. A new fellow member, DAVE, can read the open record but does not hold that position.
  • New pins:
    • a control: the resolver projects the grant to CAROL and not to DAVE, both read the record, and only CAROL reads the comment, while DAVE's read is refused with code/status;
    • a recipient who can read the record but not the comment is withheld, by id and by email;
    • only the recipient who can read both is notified, and the author never is;
    • the single posture behaves like a walled one;
    • the withheld-recipient log carries cannot_read_thread and no_comment_id, and never the comment text.
  • Adjusted pin: the existing direct-call log pin now reads a real comment id. Before, it passed a non-existent one, which the comment read now correctly refuses.

Ablation: deleting the comment-read limb turns exactly the 5 new gate pins red. The other 10 stay green, including the stand-in control. The file was then restored, and its blob matches HEAD.

Pin, package suite, typecheck (head 88a2b6c)
pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2 src/mention-recipient-gate.integration.test.ts
  Test Files  1 passed (1) · Tests  15 passed (15) · VERDICT command-exit 0      (912eeac7c1; file unchanged since)
pnpm --filter @objectstack/plugin-audit test
  Test Files  64 passed (64) · Tests  1028 passed (1028) · VERDICT command-exit 0  (88a2b6cd2d)
pnpm --filter @objectstack/plugin-audit typecheck
  tsc --noEmit · tsc -p tsconfig.scripts.json · check:test-typecheck OK · VERDICT command-exit 0  (88a2b6cd2d)
tsc --listFiles: tsconfig.test.json compiles both the gate and its integration test
Ablation, engine layer

The test imports its subject through relative src paths, so no rebuild was involved.

node scripts/ablation-replace.mjs --delete  (the comment-read `else if` line of the verdict loop)
  anchor 1 -> 0 · blob 1ee01f81f182 -> 47e854c54865
  Tests  5 failed | 10 passed (15)   the five new gate pins red; the stand-in control green
  restore: blob == HEAD (1ee01f81f182) · git diff HEAD empty
One-time check in a real composition (throwaway, not committed)

A throwaway dogfood file booted bootStack with the real plugin-security, plugin-audit and messaging, org-bound, and drove the gate through the REST layer. All three legs ran at 88a2b6c:

  • With the fix: the gate admitted exactly the recipient who may read both the comment and the record.
  • Mutated leg: the comment-read limb was deleted and plugin-audit rebuilt. ablation-dist-preflight --absent showed the limb absent from dist/. The gate admitted the record-only recipient too, and that was the one red assertion. The rebuild exited 1 at its DTS stage after writing the JS bundles the suite consumes, so the dist/ preflight reading, not the build's exit code, certifies this leg.
  • Restore leg: the rebuild exited 0, the limb was present in dist/ again, and the result was green again.

The throwaway file was then deleted, and git status --porcelain is empty.

Acceptance notes

  • Behaviour for deployments: a mentioned user is notified only when the sets they hold grant read on the comment as well as on the record. The changeset tells administrators how to restore notifications for a population that should take part in record discussions: grant comment read in a set that population holds.
  • Producer sweep: the mention writer in audit-writers.ts is the only producer that copies sys_comment text into a notification. approval.comment in plugin-approvals notifies an approval request's own participants about approval-action comments. That is a different object with its own audience, and it is not in scope here.
  • Cost: one extra caller-scoped read per mentioned candidate who passes the record read.
  • Timing: the comment is read from inside the author's write, in afterInsert. The engine binds reads there to the write's own unit of work (the ambient transaction store), so the row is visible. A read that cannot see the row withholds, which is the fail-closed direction. The existing CAROL pins exercise this path end to end.
  • Not run locally:
    • test:repo (the vitest repo project): it belongs to CI's Test Core, and this diff changes no test in that project.
    • Full dogfood: the title has no ! and there is no (narrowing) arm.
    • packages/cli integration tier: not touched.

Verification

Local subset, run at head 88a2b6cd2d. The families CI runs are read from this PR's check-runs, not re-run here.

Derived gate list: 64 of 64 exit 0

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 64 commands for the 6 changed paths. Each ran sequentially from the worktree root, with its exit code captured before any pipe. Reconciliation (--ran, exit 0): 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED.

  • check:dual-build-cjs-loads first read exit 1. At that moment a concurrent closure build of mine was rewriting dist/, and the finding was a missing CommonJS file of a package this diff does not touch. Re-run on the quiet tree, it exited 0: 112 entry points across 69 packages load.
  • An earlier run at 428f1f9c20, before the merge of main, had 62 commands at exit 0 and 2 at exit 3 (PREREQUISITE NOT MET: check:dual-build-cjs-loads and check:i18n). Both are measured green at 88a2b6cd2d.
node scripts/check-adr-0087-registration.mjs --base origin/main          0
node scripts/check-adr-0087-registration.mjs --self-test                 0
node scripts/check-changeset-no-major.mjs --base origin/main             0
node scripts/check-ci-filter-parity.mjs                                  0
node scripts/check-closing-keyword-parity.mjs (+ --self-test)            0 0
node scripts/check-comment-mask-adoption.mjs (+ --self-test)             0 0
node scripts/check-comment-mask-corpus.mjs                               0
node scripts/check-dts-emitted.mjs --self-test                           0
node scripts/check-empty-changeset.mjs --base origin/main (+ --self-test) 0 0
node scripts/check-issue-citations.mjs                                   0
node scripts/check-plugin-teardown-shape.mjs (+ --self-test)             0 0
node scripts/check-tenant-audit-census.mjs (+ --self-test)               0 0
node scripts/ci/assert-turbo-remote-cache.mjs --self-test                0
node scripts/ci/plan-test-core-shards.mjs --self-test                    0
node scripts/ci/prune-turbo-cache.mjs --self-test                        0
node scripts/docs-audit/check-affected-docs.mjs                          0
node scripts/docs-audit/check-drift-comment.mjs                          0
node scripts/pm/release-rehearsal-clone.mjs --self-test                  0
node scripts/release-pending-publish.mjs --self-test                     0
pnpm --filter @objectstack/spec run check:duration-unit-keys             0
pnpm check:changeset-gate-self-tests · check:closed-ceremony-text        0 0
pnpm check:cross-package-test-inputs · check:dispatcher-error-vocabulary 0 0
pnpm check:doc-authoring · check:driver-memory-census · check:dts-closure 0 0 0
pnpm check:dual-build-cjs-loads (re-run on the quiet tree)               0
pnpm check:durability-log-level · check:engine-double-contract           0 0
pnpm check:error-status-conformance · check:gitlink-declared             0 0
pnpm check:i18n · check:i18n-stale-fill · check:issue-citations          0 0 0
pnpm check:keyed-text-bounds · check:lean-entry-closure                  0 0
pnpm check:logger-receiver-detach · check:nul-bytes                      0 0
pnpm check:objectql-double-limit · check:org-identifier                  0 0
pnpm check:page-declaration-shape · check:platform-object-tenancy-census 0 0
pnpm check:pm-changeset-deadline-census · check:published-files          0 0
pnpm check:query-options-erasure · check:refd-timer-probe                0 0
pnpm check:registry-log-declared · check:rest-log-spy-declared           0 0
pnpm check:slot-lookup · check:sourcemap-no-sources-content              0 0
pnpm check:system-context-census · check:test-source-alias               0 0
pnpm check:tier-file-adoption · check:type-check-coverage                0 0
pnpm check:type-check-debt · check:undeclared-dep-imports                0 0
pnpm check:watch-hint-literal · check:where-matcher                      0 0
Narrowed spot readings
require('packages/plugins/plugin-audit/dist/index.js')  -> AuditPlugin, installAuditWriters: function   exit 0
import('packages/plugins/plugin-audit/dist/index.mjs')  -> AuditPlugin: function                        exit 0
grep -naP control bytes over the six changed files                                                   no match

Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Oct 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-audit, touching 11 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))
  • content/docs/permissions/attachments-access.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))
  • content/docs/permissions/system-context.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))
  • content/docs/plugins/packages.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))
  • content/docs/ui/setup-app.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))
  • content/docs/upgrading.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))

⛔ 7 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))
  • content/docs/releases/v16.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))
  • content/docs/releases/v17/17-0.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))
  • content/docs/releases/v17/17-1.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))
  • content/docs/releases/v17/17-5.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))
  • content/docs/releases/v17/17-6.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))
  • content/docs/releases/v18/18-0.mdx (via sys_comment (literal, a string literal in COMMENT_OBJECT))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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

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

Which tree this was computed on

This run read content/docs from 5f053e0c8ffffd77dee778df46235bb02538df50 — the merge of head 88a2b6cd2d8f54cc143884a0d2a4041ee8805f08 into base 00a4593ad7a7f007c7988d94c8258510ea126e73, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5f053e0c8ffffd77dee778df46235bb02538df50 && git checkout 5f053e0c8ffffd77dee778df46235bb02538df50
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 00a4593ad7a7f007c7988d94c8258510ea126e73 88a2b6cd2d8f54cc143884a0d2a4041ee8805f08 && git checkout -B drift-repro 00a4593ad7a7f007c7988d94c8258510ea126e73 && git merge --no-ff 88a2b6cd2d8f54cc143884a0d2a4041ee8805f08

node scripts/docs-audit/affected-docs.mjs --json 00a4593ad7a7f007c7988d94c8258510ea126e73

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · session_012YjcDon6BvR9MN93ZsFq3U · 2026-10-27T06:15Z

Pinned head: 88a2b6cd2d8f54cc143884a0d2a4041ee8805f08.

  • Shape: draft → main; first body line Fixes #26453, no other closing keyword in the body. Branch claude/issue-26453-mention-gate matches the claim (comment 6433609891).
  • Scope: 6 files, all inside packages/plugins/plugin-audit/src/** plus one patch changeset; @objectstack/plugin-audit publishes (no private), so the changeset is owed and present. Clause-②: no / Metadata-protocol: none in body and changeset — the diff touches no packages/spec/src/**. No content/docs/releases/ change.
  • Diff read: the gate keeps the record read and adds each recipient's own read of the comment by id through the same evaluator (resolveReadableParentIds in the recipient's resolved, vetted envelope); fail-closed on a missing comment id (no_comment_id) and on an unreadable comment (cannot_read_thread); withheld lines carry user id + reason, never the text. The writer path passes commentId from the real afterInsert, and the integration pin's admitted-recipient case proves the positive path still notifies.
  • Pins: mention-recipient-gate.integration.test.ts 15/15; ablation of the new read turns the 5 new pins red with the control green (dev report). Package suite 64 files / 1028 tests green.
  • Gates (check-runs on the pinned head): Lint & Repo Gates success, every Type Check · job success, Test Core 1–8/8 success, Build Core success, Check Changeset success, Check PR Size success; 36 success / 11 skipped / 0 failed. mergeable_state: clean.
  • Governed surface: check-governed-merges.mjs --pr 26497 → 0 of 6 paths governed; 313 changed lines ≤ 3000 → ordinary queue landing.
  • Human floor: a runtime authorization behaviour change. Landing is on the maintainer's word in this session (2026-10-27), verbatim: 「你按照 pm-dispatch 技能的约定复核pr落地」, after 「如果有安全问题不能写issue的,直接派卡修复」.
  • Accepted deviations: a throwaway real-composition check was run outside the file surface and deleted (never committed); test:repo left to CI Test Core.
  • Open question carried: the QA item records-forms.record-discussion-mentions scores its bell clause against a mentioned persona; after this fix that clause needs the persona to read comments too. Seat check owed on the item text (not this PR).

Landing: pr_ready + automerge_enable via the relay.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 27, 2026 06:16
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 27, 2026 06:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit c3fb185 Oct 27, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-26453-mention-gate branch October 27, 2026 06:34
os-justin pushed a commit that referenced this pull request Oct 27, 2026
…n gate now asks

The mention recipient gate admits a mentioned user only when they may read
the comment as well as its record (#26497). personas[1] says the @mention
target must hold read on sys_comment; acceptance[9] and negative[6] name
both reads; revision 7 → 8 with a history row.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012YjcDon6BvR9MN93ZsFq3U
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

records-forms.record-discussion-mentions: authorization-class finding (18.2.0 wave-2 sweep) — detail withheld pending maintainer

2 participants