security: a member with no position can add a comment reaction but never remove it — the console removes by delete, and delete may not ride an everyone-anchored set #26161
Description
Ruled: 6422861164 · letter A · 2026-10-26T17:28Z
Filing class: ① product defect (security model vs console contract) — reach: public door, measured once on @objectstack/* 18.1.0 by the objectstack-ai/hotclm #135 dev (os-dev-report on objectstack-ai/hotclm#135, PR objectstack-ai/hotclm#144). Reader: objectstack triage.
Symptom
A member who holds no position can add a comment reaction but can never take it back.
sys_comment_reactionis the reactor's own row, "made and removed, never edited" (Comment reactions (ruling A amended on #22505): a reaction is the reactor's ownsys_comment_reactionrecord, andsys_comment.reactionsretires with no aggregate and no data migration #22566). The console offers remove gated ondelete(RecordDetailViewfeedReactionRemove).member_defaultsays delete is "not a baseline right; grant it per object via an ordinary (position-distributed) set".- An app's every-member set (
isDefault: true, bound to theeveryoneanchor) may not carry it:allowDelete: trueonsys_comment_reactionfailsos lintandos validatewithsecurity-anchor-high-privilege("carries delete/purge/transfer on sys_comment_reaction — the runtime will refuse the binding").
Measured as a plain member (clm_requester + member_default): add 👍 → 201; click own chip → no request; DELETE /api/v1/data/sys_comment_reaction/<own id> → 403. So removal works only for members whose position-bound set happens to carry delete, and two audiences behave differently in one panel.
Expected
An anchor-safe, own-row removal of one's own reaction for every member who may react — e.g. the platform's baseline carries an owner-scoped delete on sys_comment_reaction (the row is the reactor's own and is never edited), or the anchor rule treats an owner-only delete on this object as low-privilege.
Dedupe
MCP search_issues on this repo: member cannot remove own comment reaction delete everyone anchor high privilege sys_comment_reaction → 7 hits: #22500, #22505, #22566, #22573, #23281 (closed — the reaction-as-own-row design and its migration), #24549, #4630 (closed — comment authorization). None covers removal under the anchor rule.
Filed by the repo:hotclm PM seat from a dev report.
Activity
objectstack-fleet commentedon Oct 26, 2026
Triage: first grade —
bug·security·priority:p2·domain:server·area:access·needs-user-decision·pm:awaiting-maintainer· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-26T14:54Z⛔ Not a claim, ⛔ not a dispatch.
sys_comment_reactionrow. That is a security-boundary call, on the human floor. Today a member with no position can add a reaction and never remove it, because the console removes bydelete, anddeletemay not ride an everyone-anchored set (security-anchor-high-privilege).deletegrant. The console calls it, and the anchor rule stays as it is. Workload: S–M (server door plus the console call).sys_comment_reactiononly, so an every-member set may carryallowDelete. That grants delete on every reaction row the RLS lets the member see, a wider door than the need. Workload: S.member_default. That changes a shipped baseline set for every tenant (new-tenant defaults are on the floor too). Workload: S.Generated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
Brief-claimed-by: session_01KrKXQVvgYk2AJ4VkqMobyb · batch #401 · 2026-10-26T15:42Z
Director seat, summon #51 continuation (ledger objectstack#12708). Presented to the maintainer in batch #401 as item 2 of 5 (with #26149, batch #400's open item, re-presented beside it at the maintainer's word 「按照总监决裁格式详细写」). Seat increment (re-verified on
origin/maincdd4eea9; the card's own analysis is not restated):resolveAffordance('feedReactionRemove', …)(RecordDetailView.tsx:429, adeletegrant);security-anchor-high-privilegeis ADR-0090 D5's rule (packages/lint/src/validate-security-posture.ts:14);default-permission-sets.ts:620: deleting records is not a baseline right;sys_comment_reactionisplugin-audit's object (sys-comment-reaction.object.ts:69); the REST route ledger has no reaction-specific route, so add and remove both ride the generic data door today — which is exactly why remove needsdelete.needs-user-decision+pm:awaiting-maintainertogether (6419159811); the second leaves in the ruling's stroke.deleteto every reaction row the member can read; C changes a shipped baseline for every tenant (new-tenant defaults are on the floor). ② measured on hotclm 18.1.0 by a plain member; every member who may react is affected. ③ A refuses loudly on a foreign row; B/C depend on a grant an AI author could widen further. ④ A adds one route (a permanent surface) but no key, no gate.deleteneeded; the console calls it; the anchor rule unchanged). Workload: server S–M + ui S;Clause-②: yes (widening)on the REST face,Metadata-protocol: none.Prior rulings read:
security-anchor-high-privilege·sys_comment_reaction·#22566→ ADR-0090 D5, the baseline-set note; thread: 6419159811.objectstack-fleet commentedon Oct 26, 2026
Ruling: batch #401 item 3 · letter A · maintainer 「同意」 2026-10-26T17:27Z
Recorded by the director seat (summon #51 continuation,
session_01KrKXQVvgYk2AJ4VkqMobyb, ledger objectstack#12708). The maintainer answered batch #401 with 「同意」 — every seat letter. Brief 6420295908. Letter A, an own-row unreact door: the server gains a verb that removes only the caller's ownsys_comment_reactionrow — authorization is structural (reactor == caller; a foreign row is refused loudly), so it needs no object-leveldeletegrant and rides every member's default set; the console's reaction chip calls it instead of the genericDELETE /api/v1/data/sys_comment_reaction/:id; the anchor rulesecurity-anchor-high-privilege(ADR-0090 D5) and themember_defaultbaseline stay exactly as they are. Not B (delete on every reaction row a member can read) and not C (a shipped baseline changed for every tenant).Workload of the chosen letter (card column): server S–M (the verb in
plugin-audit/ the REST route-manager with its ledger row, integration tests for own row / foreign row / unreadable comment) + ui S (the chip's remove path,RecordDetailView.tsx:429). Precedent: none for an own-row verb; #22566 for the reaction row's shape.Execution:
needs-user-decision→pm:queuein this act (domain:server, p2,security,area:access);pm:awaiting-maintainerremoved in the same stroke (half-state beside the decision label). The server card lands first; the holding seat files thedomain:uisub-card withBlocked-by:this card.Ruling-ref:names this comment;Clause-②: yes (widening)on the REST face,Metadata-protocol: none. The hotclm consumer (objectstack-ai/hotclm#135) unblocks on a published release carrying both halves.objectstack-fleet commentedon Oct 26, 2026
Claim: PM loop round 8 · card thread
Session:
session_01R1q2xdtYgU6FKrZ7LgjPCC(the seat); the card thread issession_01TZQBvhyKHoq1oKrgnVx35YAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-tlk5tx(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#1Ruling-ref:
6422861164(letter A, maintainer 「同意」 2026-10-26T17:27Z): an own-row unreact door. The server removes only the caller's ownsys_comment_reactionrow; authorization is structural (reactor == caller), and a foreign row is refused loudly. No objectdeletegrant is needed. Not B, not C: themember_defaultbaseline andsecurity-anchor-high-privilege(ADR-0090 D5) stay exactly as they are. Security card on the maintainer's word: no advisory, no new issue.File surface:
packages/plugins/plugin-audit/**(the verb, its mount, its tests), the REST route ledger or client coverage rows a new route owes, and apatchchangeset. The console half (ui/**: the reaction chip andfeedReactionRemove) is adomain:uisub-card the seat files withBlocked-by: #26161. ⛔ Noui/**. ⛔ Nopackages/spec/**(a new error code would need one: stop and reportneeds_decision).Container & model:
S,mode:thread, default model and effort (set at thread open)Clause-②: yes (widening) — the REST face gains one route, as the ruling states; the dev measures it on the diff
Metadata-protocol: none
Responsibility: a member with no position can add a comment reaction and never remove it (
403on the generic data door), measured on hotclm 18.1.0 (objectstack-ai/hotclm#135)Thread-read: 6422861164
Premise: holds (read 2026-10-26T18:52Z at
origin/main586a7ed7d9: card open, unassigned,pm:queue, no claim; the console's remove isfeedReactionRemove→ adeletegrant (ui/packages/core/src/utils/affordanceGrants.ts:213); no reaction-specific route exists)Serial constraints cleared: #26258 (seat 1, in the merge queue) edits
packages/plugins/plugin-audit/src/audit-plugin.ts. The dev mergesmainafter it lands, beforepr_create. The dev re-measures other open PRs onplugin-auditbeforepr_create.objectstack-fleet commentedon Oct 26, 2026
os-dev-report
{
"issue": 26161,
"status": "done",
"branch": "claude/project-thread-tlk5tx",
"pr": "#26345",
"session": "session_01TZQBvhyKHoq1oKrgnVx35Y",
"premise_still_valid": true,
"summary": "Ruling 6422861164 letter A: AuditPlugin mounts DELETE /api/v1/comments/reactions/:id on http.server (no body, any query param 400). The caller is resolved through resolveRequestExecutionContext (fail-closed, gate resolved); the row is read as the caller (unreadable = 404 RECORD_NOT_FOUND, same as unknown id), its created_by must equal the caller (else 403 PERMISSION_DENIED, row stays), and only then is it deleted by id under a system context attributed to the caller in the caller's tenant. Answers: 200 {success:true,data:{id,deleted:true}} | 400 VALIDATION_ERROR | 401 UNAUTHENTICATED | 403 held-gate code | 403 PERMISSION_DENIED | 404 RECORD_NOT_FOUND | 503 SERVICE_UNAVAILABLE. No permission set gains delete, member_default and security-anchor-high-privilege are untouched, no spec edit and no new error code; patch changeset on @objectstack/plugin-audit with Clause-②: yes (widening), Metadata-protocol: none.",
"tests": "Head 0f28b47. Red first on base c496886 with the door unmounted: integration 6/8 red ('no DELETE /api/v1/comments/reactions/:id is mounted'); fixture case and generic-delete 403 case green. Green: plugin-security comment-reaction-unreact-door.integration.test.ts 8/8 (real kernel, SqlDriver in-memory, real SecurityPlugin with shipped sets plus an app every-member set granting read/create only, real AuditPlugin, real caller resolution over seeded sys_user/sys_member, plain member with no position): own row 200 + gone; foreign 403 PERMISSION_DENIED + stays; unreadable comment's reaction 404 RECORD_NOT_FOUND equal to unknown id + stays; unknown id 404; anonymous 401; query param 400; generic engine delete as member via mapDataError 403 PERMISSION_DENIED + stays. Ablation on 0f28b47 (ablation-replace.mjs deleting
if (row.created_by !== callerId) throw notYourReactionError(id);): rebuilt, dist-preflight --absent ✓ all 6 files, Tests 1 failed | 7 passed, the failure = foreign-row case alone; restore: blob ebd0da613b26 == HEAD, rebuild marker present in dist/index.js + dist/index.mjs, tree clean, Tests 8 passed (8). Packages: plugin-audit test 64 files / 1023 tests passed; plugin-security test 229 files, 4942 passed | 45 skipped (lock VERDICT command-exit 0); typecheck 0 for both; eslint --no-inline-config 0 for both; vitest --project repo green in core (83), objectql (5), rest (280 | 18 skipped), runtime (947), spec (1279), types (11).","gates": "Local, head 0f28b47: all 122 commands from dispatch-gates.mjs --commands exited 0; --ran: 122 derived, 122 run, 0 NOT-MEASURED, 0 UNRUN. CI on the PR: at hand-back 1 success, 2 skipped, 22 in progress; the seat holds the PR's events.",
"dogfood_battery": "n/a — no
!in the PR title and no (narrowing) arm","mcp_calls": "0",
"api_writes": "2 fleet-write relay dispatches (POST /repos/objectstack-ai/objectstack/actions/workflows/…/dispatches), carrying 3 writes as objectstack-fleet[bot]: POST /repos/objectstack-ai/objectstack/pulls (#26345, draft), POST /repos//issues/26345/assignees (os-project-manager), POST /repos//issues/26161/comments (this report). git push ×3 (not REST). No claim, no card assignee write, no subscription.",
"open_questions": [],
"out_of_scope_findings": [
"File surface beyond plugin-audit/** (declared, coverage rows the route owes): plugin-security/src/comment-reaction-unreact-door.integration.test.ts (plugin-security already devDepends on plugin-audit; the reverse edge is a cycle), scripts/check-route-envelope.mjs MODULES row, scripts/check-single-authz-resolver.mjs DELEGATORS row, tenant-audit census regenerated (content/docs/permissions/tenant-audit-census.mdx + docs/audits/2026-08-tenant-audit-write-call-sites.counts.md, 243→244).",
"carrier: seat 1 domain:ui sub-card (Blocked-by: #26161) · @objectstack/client has no method for the route (ledger row disposition gap); the console reaches the server through ObjectStackClient (ui/packages/data-objectstack), so the ui half needs a client method or a direct call · noted, not filed",
"carrier: none · the dogfood live-mount parity gate (route-ledger-live-mount-parity.dogfood.test.ts) boots the showcase without AuditPlugin, so this ledger is outside its union; the package-local conformance test guards the route · noted in PR Acceptance notes, not filed",
"carrier: none · the runtime request-execution-context.conformance.test.ts DOORS table lists the five converted doors only; this door (like plugin-auth's) is not a row there · noted, not filed"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
Landed
PR #26345 merged at 2026-10-26T22:40Z by the merge queue as
1a298dda8donmain(Fixes #26161; ACCEPT6428694974).git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26345-*'origin/mainby contentgit cat-file -e origin/main:packages/plugins/plugin-audit/src/comment-reaction-routes.tsgit merge-base --is-ancestor 1a298dda8d origin/mainState: this card closed by
Fixes(completed);pm:dispatchedremoved.Generated by Claude Code