Repository navigation
feat(plugin-audit): sys_comment_reaction, the reactor's own record of an emoji reaction - #22603
Conversation
… an emoji reaction A reaction is one (comment, emoji, user) row owned by the reactor. The comment gate's two installers answer for it with the same handler: a reaction is readable exactly when its comment is (one caller-scoped read of sys_comment), and created only on a comment the caller can read, with user_id stamped from the session. Delete is the platform's own-record floor; update is not offered. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <[email protected]>
… and uniqueness Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <[email protected]>
…s shipped Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <[email protected]>
…y census Regenerated with node scripts/platform-object-tenancy-census.mjs --write. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <[email protected]>
node scripts/check-i18n-bundles.mjs --write --filter=audit Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <[email protected]>
…mment-reaction-record
…ys_comment_reaction Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <[email protected]>
📓 Docs Drift CheckThis PR changes 2 package(s): 62 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 141 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8efaab6e9a65315efd9b415737c26022805f8993 && git checkout 8efaab6e9a65315efd9b415737c26022805f8993
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d748ae80afe6ed63d1c61a51a69f4fb18280c844 9f26b002a1dc1225c27b15b3472ad678dcfe3609 && git checkout -B drift-repro d748ae80afe6ed63d1c61a51a69f4fb18280c844 && git merge --no-ff 9f26b002a1dc1225c27b15b3472ad678dcfe3609
node scripts/docs-audit/affected-docs.mjs --json d748ae80afe6ed63d1c61a51a69f4fb18280c844
|
…mment-reaction-record
|
CI red at
The dev's patch round pushes both fixes and re-runs the spec repo tests locally. No re-run is spent on this head. Generated by Claude Code |
…lugin-audit's platform object registry A whitelist that grants create and delete grants bulk too (api-methods-batch-conformance); the batch doors still admit only children allowed one at a time, so updateMany stays refused. PLATFORM_OBJECTS_BY_PACKAGE['plugin-audit'] lists the object the package now registers. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <[email protected]>
…gate per row, deleteMany keeps the own-record floor Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <[email protected]>
…bject registry; the batch doors Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <[email protected]>
…mment-reaction-record
Contract reviewServed-tier: PR #22603 (branch Check-runs on the head (latest run per name, read in this act): 35 names, 30 ① Derived judgmentsEvery accept-set or public-surface change the diff implies, each with the verdict on how the PR names it.
② Semver level
③ Boundary flagsEvery dev flag and every
One observation, not a FAIL reason and not pinned either way: whether a reactor can still delete their own reaction by id once its comment is gone or no longer readable to them is not pinned; the orphan is read by nobody (pinned), and no ruling asks for a retraction path. If a chatter card needs one, it is a question for that card. Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22566
Clause-②: yes
The additive half of ruling A, amended, on #22505 (director record
6091550406, delegated judgment6091621568), scoped by triage6092508551and claim6092717444. A new object,sys_comment_reaction, holds one emoji reaction per row, and the row belongs to the user who reacted. The retirement ofsys_comment.reactionsis #22573 and is not touched here: the field, its description and its bundles are byte-identical. #22500 remains open; it waits on the full ruling, not on this PR.Why the first line is
Part of. One piece of the card's scope cannot be built as the card words it, so it is back with the seat as a decision (see "Who grants the bits" below). Everything else the card scopes is in this diff. If the decision lands on option B, nothing here changes and the first line can be rewritten to close the card.What this adds
plugin-audit/src/objects/sys-comment-reaction.object.ts, registered besidesys_commentinAuditPlugin.initand provisioned with it). Fields:comment_id(text, at most 255 characters),emoji(text, at most 64),user_id(lookup tosys_user),created_at.apiMethods: ['get', 'list', 'create', 'delete'];userActions: { edit: false, import: false };feeds: false,activities: false. NosharingModel, and the audit columns are injected, socreated_byexists and is stamped.403 RECORD_NOT_ACCESSIBLE(the comment gate's own code, no new code).user_idis stamped from the session, and a value the client sends is replaced. A session with no user is refused.owner_only_deletes(created_by == current_user.id, positionsorg_member), is the whole rule: a member deletes their own reaction and not another member's. No moderation-style alternate match is contributed for reactions.apiMethods). A reaction is deleted and made again.platform-object-tenancy-census.mjs --write,check-i18n-bundles.mjs --write --filter=audit). The zh-CN, ja-JP and es-ES leaves are the generator's source fills, with provenance records.The read rule, and why this one
Measured options:
controlled_by_parenton amaster_detailtosys_comment. Rejected. ADR-0055 §3 makes every write to a detail require EDIT on its master, and a member cannot edit another member's comment (the author-only floor), so it would refuse exactly the reaction the ruling admits. Its read half is also the wrong question: the master set comes from the master's RLS and sharing filters, which never run the comment's thread gate, because that gate is an engine middleware, not a row policy.comment-access-hooks.tsnow answer forsys_comment_reactiontoo, with the same handler registered on both objects. The reaction read gate pre-scans the candidatecomment_ids under system context, then hands them toresolveReadableParentIdsas one caller-scoped read ofsys_comment. That read runs everything a direct read of those comments runs: the caller's grant onsys_comment, its row scope, and the thread gate, which in turn asks the record each thread names. The result is ANDed into the reaction query ascomment_id$in. It coversfind,findOne,countandaggregate. Analytics routes the object through the engine, because it carries an object middleware. The create gate asks the same evaluator, so the create door never admits a reaction the read door would hide.Why one handler per seam and not one per object: the system and context-less exemption each installer opens with is read once for both objects. The
isSystemcensus (check:system-context-census) stays at 123 read sites, with no new anchor needed.Uniqueness
(comment_id, emoji, user_id)is one row, held by a declared unique index with its scope spelled out (unique: 'organization', ADR-0120 D1). That is the platform's existing answer. A second identical reaction is refused by the store: the engine envelope isDUPLICATE_RECORD409, and the data door answers it as409 UNIQUE_VIOLATION, pinned throughmapDataError. Two members reacting at once keep two rows. No new error code.Why
comment_idis an id column and not a lookupReferential behaviour on delete runs as the caller (
cascadeDeleteRelationsrecurses with the caller's context; thecascadearm is excluded from the referential-clear exemption by ruling). Acascadelookup would make an author's delete of their own comment delete other members' reactions under the author's authority, and the own-record floor refuses that. A requiredset_nulllookup escalates torestrict. Either would stop an author deleting their own comment once someone had reacted. Socomment_idis an id column, likesys_comment.thread_id. A deleted comment's reactions stay in storage and are read by nobody, because the read gate fails closed on a comment that no longer exists. This is pinned.Who grants the bits: the open decision
The card asks for "permission-set rows (create, read, delete-own)". Measured on
origin/main25be87612d:default-permission-sets.ts:1164–:1218is thesys_comment_moderationdelete alternate and nothing else. The platform ships no create or read bit onsys_comment. An app grants those: hotcrm's members comment because fix(profiles, docs): reps read and join the Discussion of records they can read; the expiry and renewal sweeps run where scheduled work is on; the automation page names the shipped templates (#2029 items 3, 5, 6) hotcrm#2043 grantssys_commentread, create and edit. The showcase and CRM examples grant members nothing onsys_comment.member_default, theeveryonebaseline. ADR-0090 D5 hard-blocksallowDeletethere.describeHighPrivilegeBitswould answer "delete/purge/transfer on 'sys_comment_reaction'",bindBaselineToEveryonewould then refuse to bind the whole baseline, andaudience-anchor-set-claims.pin.test.tspinsmember_defaultanchor-safe. So no shipped set can hand every member a delete bit.owner_only_deletesalready does it (pinned below).Options, which the report on #22566 sets out on the four axes:
member_defaultgrants read and create on reactions (anchor-safe), and delete stays app-granted. A member on an app that grants no delete can add a reaction and never remove it.sys_comment_reactionwherever it grantssys_comment, and the platform's floor keeps the delete to the reactor's own row. A missing grant fails loudly on first use, for adding and removing alike.everyone-bound set, then grant all three inmember_default. This is a Tier H ADR plus apackages/specpredicate change.Recommended: B. The changeset states the B posture and says the baseline grants nothing.
Census of
sys_comment.reactionsconsumers (report only, no edits)git grep reactionsover the tree at this head): the field's declaration (plugin-audit/src/objects/sys-comment.object.ts) and the four generated bundles. No runtime reader or writer.packages/spec/src/ui/component.zod.tsenableReactions(RecordActivityProps) is a feed display toggle that reads no column, so it is a different surface and stays out.maind758f2f20d: one reader and one writer, bothpackages/app-shell/src/views/RecordDetailView.tsx. The read isdataSource.find('sys_comment', …)at:1907, parsingc.reactionsat:1920. The write isupdate('sys_comment', id, { reactions: JSON.stringify(…) })at:2254–:2255. Downstream renderers take the parsed value, never the column:plugin-detailRecordActivityTimeline.tsxandReactionPicker.tsx(FeedItem.reactions,types/src/views.ts:650).collaboration/src/CommentThread.tsx:25takes areactionsprop, and no in-repo caller feeds it. At the pinned.objectui-sha47b1f0bb71the same single site appears at:1884,:1897and:2226–:2227.6091621568) is that cloud has no reader.Tests
plugin-audit/src/comment-reaction-access.integration.test.ts(real kernel, realObjectQLPlugin, real SQLite driver,AuditPlugin). For every caller, the comments its readable reactions point at equal the reacted comments it can read. It also pinsfind,count,findOneandaggregate; the reader's one batchedcomment_id$inread, which makes one comment probe per read and never one per row; the create refusals on an unreadable comment, a missing comment, an emptycomment_idand a session with no user (codeplusstatus);user_idandcreated_bystamped with the reactor; two concurrent reactions kept as two rows; the duplicate refused as 409 and mapped toUNIQUE_VIOLATION; and a comment's author deleting the comment with others' reactions on it.plugin-audit/src/objects/sys-comment-reaction.object.test.tsholds the declaration to what the other rules read: nosharingModel, audit columns on,comment_idwith no reference or delete behaviour, the index and its scope,apiMethodswithoutupdate, and no managed-affordance conflict.plugin-security/src/comment-reaction-own-record-floor.test.tsruns the shippedmember_defaultand a realSecurityPluginover a real engine and driver. It pins that a member creates a reaction on another member's comment and deletes it; that another member cannot delete it, by id (PERMISSION_DENIED403) or by predicate, and the row survives; and that two concurrent reactions each delete only by their reactor.plugin-security/src/objects/default-permission-sets.test.ts: no shipped set carries a row-level policy onsys_comment_reaction(a tripwire against a moderation-style alternate).Ablations (committed heads
8361ea8d82and again32c30d8034, each throughscripts/ablation-replace.mjs, disk-verified, restored blob equal to HEAD)The subjects resolve from source (
plugin-security's ownsrc, andplugin-audit's ownsrcvia./audit-plugin.js), so nodist/sits on the path and no rebuild leg applies.owner_only_deletesobject'*'changed to an object that does not exist (the own-record floor dropped)8361ea8d82only)VALIDATION_FAILED,user_idrequired, since the stamp is gone), 21 skippedallowDeleteset to falseOn the card's ablation, "drop the own-record row and the first pin goes red": the own-record row is the platform floor, and dropping it (A1) reddens "cannot delete another member's reaction", not the first pin. The first pin rests on the object grant (A4), and the grant's home is the open decision.
Gates
Local run at head
32c30d8034. Exits are captured before any pipe.@objectstack/plugin-audit:vitest rungave 44 files and 701 tests passed (atcdd0198080). The two reaction test files were re-run at32c30d8034after their options were typed: 21/21 and 8/8.typecheckexit 0, with the test layer compiled undertsconfig.test.json; both new test files are in that program (--listFilesOnly).@objectstack/plugin-security:vitest rungave 194 files, 4079 passed and 45 skipped (at1c5d95aa8c; only the two test files below changed after that). The floor test anddefault-permission-sets.test.tswere re-run at32c30d8034: 31/31.typecheckexit 0, with the test layer compiled.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths given, from the merge base99801d831f. 76 families were derived, 76 run and 76 exited 0. Reconciled with--ran(each command recorded with its exit): "76 run, 0 NOT-MEASURED (a DERIVED zero)". The census gates' verdict lines:check:platform-object-tenancy-censusreports 84 platform-namespace objects, 49 in reach;check:system-context-censusreports 123 elevation read sites, with the page holding 118 of 118 symbols;check:tenant-audit-censusis OK;check:i18nreports 9 packages in sync.check:i18n-coverage(not derived for this diff). Its prerequisite closure includes the four example apps, which were not built here, so it is left to CI.Acceptance notes
sys_audit_logstill records every reaction create and delete: the ledger capture is always on, andactivities: falsestops only thesys_activitymirror. If the ledger's volume matters, theSKIP_OBJECTSset inaudit-writers.tsis where to change it. That file is not touched here, because PR fix(plugin-audit): sys_activity.actor_name carries the acting user's display name, written with the row #22526 holds it.comment-access-hooks.ts's first header paragraph still says the default member sets "grant wildcard CRUD";member_defaulthas had no wildcard sincemember_default's*wildcard object grant (C/R/E) union-merges into every org member — app-side explicit-allow object gates are erased on three axes #5491. This is stale prose, left as it was.isSystemcensus page (content/docs/permissions/system-context.mdx, row 45) describes the comment hooks without naming reactions. Its anchors and counts still hold, and the prose is a docs-only follow-up.PATCHon the object is NOT MEASURED. The declaration is pinned (apiMethodswithoutupdate), and the door's405comes from the existingapiMethodsmachinery, not from this diff.comment_idandemoji(user_idis stamped and may be omitted). Read with onefindoncomment_id$inthe feed's ids. Remove by deleting the caller's own row by id. A repeated reaction answers 409UNIQUE_VIOLATION.Seat’s append: the open decision is answered (B)
Appended by
domain:servicesseat 1 (session_013j5gkUCpqQiti4GgPqqmnt) at 2026-10-10T04:01Z. The seat answered the dev’s open question in-seat as B, on #22566 (the answer comment there): the governing texts already settle it. The maintainer’s ruling recorded onmember_default(#5491) says object access is the application’s to declare, and ADR-0090 D5 hard-blocksallowDeleteoneveryone-bound sets. So no code changes, and line 1 is rewritten fromPart of #22566toFixes #22566by the seat. Where the sections above call the bits’ home “the open decision”, read it as decided: application permission sets, beside theirsys_commentgrant, withowner_only_deleteskeeping a delete to the reactor. #22500 remains open; it also waits on objectstack-ai/objectui#12078.Seat's append: patch round 1 (head
9f26b002a1)Appended by
domain:servicesseat 1 (session_013j5gkUCpqQiti4GgPqqmnt) at 2026-10-10T05:27Z, from the dev's round-1 report (6094184898on #22566). Where the sections above describe the object'sapiMethods, or report results at32c30d8034, this section supersedes them.What this adds: the object (replaces the first bullet)
plugin-audit/src/objects/sys-comment-reaction.object.ts, registered besidesys_commentinAuditPlugin.initand provisioned with it). Fields:comment_id(text, at most 255 characters),emoji(text, at most 64),user_id(lookup tosys_user),created_at.apiMethods: ['get', 'list', 'create', 'delete', 'bulk'];userActions: { edit: false, import: false };feeds: false,activities: false. NosharingModel, and the audit columns are injected, socreated_byexists and is stamped.@objectstack/spec's platform object registry (PLATFORM_OBJECTS_BY_PACKAGE['plugin-audit']) lists it.The batch doors (
bulk)The object grants
bulk, asapi-methods-batch-conformance.test.tsrequires of every whitelist that grants single-record writes. It opens no path around the rules above:bulk ∧ derived(child): a batched child is admitted only when it is allowed on its own.updateis not offered, soupdateManyand anupsertchild stay refused.createMany(createManyData) is oneengine.insertof the rows, and the partial-success batch (insertManyData) isengine.insertMany. The engine runsbeforeInsertonce per row in both, so every row meets the create gate. Each row is stamped with the session's user, whatever it sent. A row on a comment the caller cannot read refuses the whole batch with403 RECORD_NOT_ACCESSIBLE, and nothing is written. Pinned incomment-reaction-access.integration.test.tsunder "bulk create" (3 tests).deleteMany(deleteManyData) deletes one id at a time, by id, so every row meetsowner_only_deletes. The caller's own reactions are deleted, and another member's fails on its own row (PERMISSION_DENIED) and survives. Pinned incomment-reaction-own-record-floor.test.tsunder "the batch doors keep the own-record floor", driven through the realObjectStackProtocolImplementationover the real engine.comment_id$inread is the pinned case.11e9afaac5. A1, dropping the floor, reddens thedeleteManypin. A3b, short-circuiting the comment-readability check, reddens both bulk-create refusal pins.Round 1 (patch): files and results at
9f26b002a1The two CI reds at
32c30d8034(Test Core 1/6,@objectstack/spec#test:repo) were this PR's own, and both are fixed at the producer:api-methods-batch-conformance.test.ts: the object now grantsbulk(see "The batch doors").platform-object-names.test.ts:PLATFORM_OBJECTS_BY_PACKAGE['plugin-audit']now listssys_comment_reaction. This is the onepackages/specfile, declared by the seat's claim amendment6093649317under triage's cross-domain exception.check:generatedproves all 15 generated artifacts current, so none was regenerated. The changeset now grades@objectstack/specminorfor the new registry name.Files (21, +1300 / −63 against merge base
d748ae80af):.changeset/22566-comment-reaction-record.mdpackages/plugins/plugin-audit/scripts/i18n-extract.config.tspackages/plugins/plugin-audit/src/audit-plugin.ts,audit-plugin.test.tspackages/plugins/plugin-audit/src/comment-access-hooks.tspackages/plugins/plugin-audit/src/comment-reaction-access.integration.test.tspackages/plugins/plugin-audit/src/objects/index.ts,sys-comment-reaction.object.ts,sys-comment-reaction.object.test.tspackages/plugins/plugin-audit/src/translations/bundle-ownership.test.ts, and the seven generated bundle files (en/es-ES/ja-JP/zh-CN.objects.generated.ts, three.source-hashes.generated.ts)packages/plugins/plugin-security/src/comment-reaction-own-record-floor.test.tspackages/plugins/plugin-security/src/objects/default-permission-sets.test.tspackages/spec/src/system/constants/platform-object-names.tsscripts/platform-object-tenancy-census.jsonResults (under the verify lock, exits captured before any pipe):
@objectstack/spec: the repo project gave 54 files and 915 tests passed (ate4e0c355ca). Of those, the two files CI failed gave 15/15.check:generated: 15 of 15 up to date at9f26b002a1.@objectstack/plugin-audit: 44 files and 704 tests passed.typecheckexit 0, with the test layer compiled.@objectstack/plugin-security: 194 files, 4080 passed and 45 skipped.typecheckexit 0, with the test layer compiled.dispatch-gates --ran: "98 run, 0 NOT-MEASURED, a derived zero").check:dual-build-cjs-loadsneeded four unrelated packages built first, and was then re-run green.check:i18n-coverageis OK: 13 configs, none new.11e9afaac5: A1 3 red / 1 green, A2 9 red / 15 green, A3b 5 red / 19 green, A4 3 red / 1 green. Every leg was restored to a blob equal to HEAD.9f26b002a1(the seat's reading, latest run per check name): 32 success, 3 rostered skips.Generated by Claude Code