Skip to content

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

Closed

Description

@objectstack-fleet

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.

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

  1. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    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.

    • For the maintainer: the fix decides who may delete a sys_comment_reaction row. 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 by delete, and delete may not ride an everyone-anchored set (security-anchor-high-privilege).
    • Options:
      • A, an own-row "unreact" door (triage leaning). The server removes only the caller's own reaction row (it enforces reactor == caller) and needs no object delete grant. The console calls it, and the anchor rule stays as it is. Workload: S–M (server door plus the console call).
      • B, relax the anchor rule for sys_comment_reaction only, so an every-member set may carry allowDelete. That grants delete on every reaction row the RLS lets the member see, a wider door than the need. Workload: S.
      • C, platform baseline own-row delete on reactions in member_default. That changes a shipped baseline set for every tenant (new-tenant defaults are on the floor too). Workload: S.
    • Lean: A. It meets the need (take back your own reaction) without widening any delete grant or touching a baseline.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    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/main cdd4eea9; the card's own analysis is not restated):

    • Premises hold: the console's remove is resolveAffordance('feedReactionRemove', …) (RecordDetailView.tsx:429, a delete grant); security-anchor-high-privilege is 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_reaction is plugin-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 needs delete.
    • Half-state read, not written: needs-user-decision + pm:awaiting-maintainer together (6419159811); the second leaves in the ruling's stroke.
    • Facets (the card has no block; seat's reading): ① A keeps ADR-0090 D5 intact and adds one own-row verb whose authorization is structural (reactor == caller), not a grant — the narrowest door; B widens delete to 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.
    • The seat agrees with A (an own-row unreact door in the server, no object delete needed; 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.
  3. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    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 own sys_comment_reaction row — authorization is structural (reactor == caller; a foreign row is refused loudly), so it needs no object-level delete grant and rides every member's default set; the console's reaction chip calls it instead of the generic DELETE /api/v1/data/sys_comment_reaction/:id; the anchor rule security-anchor-high-privilege (ADR-0090 D5) and the member_default baseline 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:queue in this act (domain:server, p2, security, area:access); pm:awaiting-maintainer removed in the same stroke (half-state beside the decision label). The server card lands first; the holding seat files the domain:ui sub-card with Blocked-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.

  4. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 8 · card thread
    Session: session_01R1q2xdtYgU6FKrZ7LgjPCC (the seat); the card thread is session_01TZQBvhyKHoq1oKrgnVx35Y
    Account: 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:server
    Seat: domain:server#1
    Ruling-ref: 6422861164 (letter A, maintainer 「同意」 2026-10-26T17:27Z): an own-row unreact door. The server removes only the caller's own sys_comment_reaction row; authorization is structural (reactor == caller), and a foreign row is refused loudly. No object delete grant is needed. Not B, not C: the member_default baseline and security-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 a patch changeset. The console half (ui/**: the reaction chip and feedReactionRemove) is a domain:ui sub-card the seat files with Blocked-by: #26161. ⛔ No ui/**. ⛔ No packages/spec/** (a new error code would need one: stop and report needs_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 (403 on 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/main 586a7ed7d9: card open, unassigned, pm:queue, no claim; the console's remove is feedReactionRemove → a delete grant (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 merges main after it lands, before pr_create. The dev re-measures other open PRs on plugin-audit before pr_create.

  5. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  6. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed

    PR #26345 merged at 2026-10-26T22:40Z by the merge queue as 1a298dda8d on main (Fixes #26161; ACCEPT 6428694974).

    Reading Probe Result
    Queue membership git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26345-*' 0 refs
    origin/main by content git cat-file -e origin/main:packages/plugins/plugin-audit/src/comment-reaction-routes.ts present
    Ancestry git merge-base --is-ancestor 1a298dda8d origin/main holds

    State: this card closed by Fixes (completed); pm:dispatched removed.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions