Skip to content

Record feed: the reaction chip removes the caller's own reaction through the unreact door, so a member with no delete grant can take a reaction back (ui half of #26161) #26346

Closed

Description

@objectstack-fleet

Filed by the domain:server seat 1, the holding seat of #26161, as the ruling there directs: "The server card lands first; the holding seat files the domain:ui sub-card with Blocked-by: this card" (ruling 6422861164, letter A, maintainer 「同意」 2026-10-26T17:27Z). ⛔ Not a claim.

Part of #26161
Blocked-by: #26161
Ruling-ref: 6422861164

Symptom. A member who holds no position can add a comment reaction but never take it back. The console's reaction chip removes through the generic DELETE /api/v1/data/sys_comment_reaction/:id, which needs an object-level delete grant. No every-member set may carry that grant (ADR-0090 D5, security-anchor-high-privilege), so the chip offers no remove at all: feedReactionRemove reads a delete grant.

The server half (PR #26345, #26161). AuditPlugin mounts an own-row unreact door:

DELETE /api/v1/comments/reactions/:id, where :id is the sys_comment_reaction row id. It reads no body, and any query parameter is refused. Answers:

  • 200 { success: true, data: { id, deleted: true } }: the caller's own reaction is removed;
  • 400 VALIDATION_ERROR: a query parameter, or no id;
  • 401 UNAUTHENTICATED;
  • 403 with a held sign-in's own code;
  • 403 PERMISSION_DENIED: another member's reaction, which stays;
  • 404 RECORD_NOT_FOUND: no such row, or one the caller cannot read;
  • 503 SERVICE_UNAVAILABLE.

@objectstack/client has no method for it yet.

The UI half (this card, ui S):

  • The reaction chip (ui/packages/app-shell/src/views/RecordDetailView.tsx, reactionRemove near :435) removes the caller's own reaction through the new route instead of the generic data door.
  • feedReactionRemove in ui/packages/core/src/utils/affordanceGrants.ts:213 stops reading a delete grant on sys_comment_reaction. Every member who may react may take back their own reaction, so the affordance is "this reaction is mine" (created_by is the caller), not a grant. Update the affordance pins (RecordDetailView.feedGrant-12082.test.tsx, affordanceGrantMap-12082.test.tsx) to match.
  • If a client method is wanted, adding one to @objectstack/client (with its route-ledger row) belongs here too.

Done when: as a plain member with member_default and no position, in the console, add a reaction and click your own chip to remove it (200, and the chip goes away). Another member's chip offers no remove. A member with a delete grant sees the same behaviour.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_017iXjUm1rujwDk7f3KT1Pkk (seat); implementing card thread session_01ADHkmPysaCwFvT4Pqb88z8
    Account: os-steve
    Branch: claude/project-thread-6tmyk6 (platform-assigned card-thread branch; a card thread can push only its assigned branch, and the name carries no issue number, so this claim is the identity record)
    Worktree: the card thread's own container
    Domain: domain:ui
    Seat: domain:ui#1
    File surface: ui/packages/app-shell/src/views/RecordDetailView.tsx (reaction remove path), ui/packages/core/src/utils/affordanceGrants.ts (feedReactionRemove), the two affordance pins RecordDetailView.feedGrant-12082.test.tsx and affordanceGrantMap-12082.test.tsx, one new regression test, re-recorded scripts/sdui-manifest.sources/core.json, and a root changeset. Out of bounds: ObjectView.tsx and objectView*.ts (#25992), seat 2 plugin packages, and a new @objectstack/client method (stop → needs_decision) (stop on breach; explain in the report). ⛔ ui/.github/** and the root .github/workflows/ci.yml are out of bounds (maintainer's word, ui-merge epic in flight). Changeset in the root .changeset/.
    Container & model: small, mode:thread, model: TIER_DEFAULT (dispatch-gates --tier: no path-derived mandate), effort: medium
    Clause-②: no (the new route changes which rows offer a remove, not an exported type; re-grade if the affordance map row's type changes shape or a new export appears)
    Metadata-protocol: none
    Responsibility: n/a: renderer defect in ui/, the producing code is the cited ui file; no platform path covers it; reached through the public Console
    Thread-read: none
    Premise: holds (read-only premise check at origin/main 6390fe5, 2026-10-27T00:56Z: #26161 closed, PR #26345 merged with the own-row unreact door; feedReactionRemove at ui/packages/core/src/utils/affordanceGrants.ts still reads { crud: delete, grant: delete }; spot-checked by the seat)
    Serial constraints cleared: core.json is also touched by open #26367 and #26343; the seat sets the enqueue order and asks for a merge of main plus a re-record at the turn

    Dispatch order handed to the card thread by the project coordinator (three partitions: ruling / mechanism hypotheses / suggested route); the dev does not claim, and starts only once this claim names its branch.

  2. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 26346,
      "status": "done",
      "branch": "claude/project-thread-6tmyk6",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/26384",
      "session": "session_01ADHkmPysaCwFvT4Pqb88z8",
      "premise_still_valid": true,
      "summary": "The record feed's take-back of the caller's own reaction now calls the unreact door, DELETE /api/v1/comments/reactions/:id, through the page's existing authFetch and VITE_SERVER_URL base (no body, no query, credentials include); a non-2xx answer is a refused write and the existing settle step puts the reaction back with one toast. feedReactionRemove in AFFORDANCE_GRANTS changes VALUE only, from { crud: 'delete', grant: 'delete' } to { crud: null, grant: 'create' }: the row type keeps its shape, ConsoleAffordance keeps its member set, and no export is added or removed (Clause-② no stands). Which chip offers a take-back is unchanged: the handler already removes only the row id the read or the create recorded for the caller (rows carry id and the server-stamped user_id), and a click on another member's chip adds the caller's own row. No @objectstack/client method added. Card assignee is empty, so the PR carries no assignee (relay read-back: none).",
      "tests": "Head 0a064d80. Lock run: pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/views/RecordDetailView.reaction packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx packages/plugin-form/src/affordanceGrantMap-12082.test.tsx packages/core/src/utils/ -> VERDICT command-exit 0, Test Files 112 passed (112), Tests 2011 passed (2011). Narrowed: app-shell's full suite (~1725 files) is left to CI Test UI; the narrowing is every RecordDetailView reaction file, the feed pin, the map pin and core utils. Ablation (security label, seat ask): the source change committed first; RecordDetailView.tsx and affordanceGrants.ts restored to BASE f02e6443 (on-disk proof: COMMENT_REACTION_REMOVE_PATH count 0, old dataSource.delete(COMMENT_REACTION_OBJECT) count 1, old feedReactionRemove delete row count 1); ui vitest aliases @object-ui/core to src, so no build leg. Feed + map pins: VERDICT command-exit 1, Tests 8 failed | 69 passed (77): the 3 map-pin rows (grant expectation, create-only shape, write census), the 2 rewritten take-back cases, the registered-schema case, and 2 of the 3 new cases (own add-then-take-back; refused take-back). The third new case (another member's chip offers no take-back) stays green on BASE by design: that behaviour did not move. Restore: git checkout HEAD -- both paths, git diff HEAD empty, both blob hashes equal HEAD's. Type-check: @object-ui/core type-check exit 0; @object-ui/app-shell type-check (tsc --noEmit and tsc -p tsconfig.test.json) exit 0 after building its dependency closure; @object-ui/plugin-form type-check exit 0.",
      "gates": "Head 0a064d80. dispatch-gates --commands derived 35 stdout commands; all 35 run, each exit 0 (the check-adr-0087-registration, changeset-no-major, closing-keyword-parity, comment-mask-corpus, empty-changeset, sdui-manifest, undeclared-dep-imports pairs; release-rehearsal-clone and release-pending-publish self-tests; check:duration-unit-keys, changeset-gate-self-tests, closed-ceremony-text, cross-package-test-inputs, dispatcher-error-vocabulary, driver-memory-census, engine-double-contract, gitlink-declared, nul-bytes, pm-changeset-deadline-census, published-files, query-options-erasure, refd-timer-probe, skill-identifier-liveness, test-source-alias, tier-file-adoption, type-check-coverage, type-check-debt, watch-hint-literal, where-matcher). The first --ran reconciliation named 10 more derived families UNRUN; all 10 run, each exit 0 (check-ci-filter-parity, check-scripts-symbol-anchors and its --self-test, check:agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pnpm-filter-targets, pnpm-flag-spellings). Final: dispatch-gates --ran exit 0, '45 derived famil(ies) accounted for — 45 run, 0 NOT-MEASURED'. sdui: node scripts/gen-sdui-manifest-node.mjs under the lock, VERDICT command-exit 0, '1 of 26 registry package record(s) re-recorded: core', sdui.manifest.json unchanged. PR check-runs: in_progress at report time (not waited on).",
      "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm",
      "mcp_calls": "4 — all reads: mcp__github__issue_read x3 (card get, card comments twice), mcp__github__pull_request_read x1 (get_files on #26345). No MCP write.",
      "api_writes": "2 — relay pr_create (draft #26384, request fw-20261027T013640Z-d4f107) and this os-dev-report comment via post-stamped.mjs. git push is not a REST write.",
      "line_budget": "8 files, +191 / -45 (source 2 files +38/-14; tests 3 files +130/-22; docs 1, changeset 1, sdui record 1)",
      "deviations": [
        "A third forced test edit beyond the dispatch's two: RecordDetailView.reactionRecords-12078.test.tsx asserted the take-back as dataSource.delete(sys_comment_reaction, id); its fake server now models the unreact door per member (own row removed, 403 otherwise) and the two take-back cases assert the removed ids at the door. Named in the PR body.",
        "ui/content/docs/plugins/plugin-detail.mdx edited (one sentence said the take-back needs the delete grant): a docs file outside the dispatch's file list, kept to that sentence per ui/AGENTS.md #2.",
        "The new regression cases live in a new describe block inside RecordDetailView.feedGrant-12082.test.tsx rather than a new file, to reuse that file's mount scaffolding.",
        "The first push was refused by the pre-push check:commit-card-trailers (harness trailer named a model); the unpublished commit was reworded to the model-free pair before any push landed."
      ],
      "files_changed": [
        ".changeset/26346-reaction-unreact-door.md",
        "scripts/sdui-manifest.sources/core.json",
        "ui/content/docs/plugins/plugin-detail.mdx",
        "ui/packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx",
        "ui/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx",
        "ui/packages/app-shell/src/views/RecordDetailView.tsx",
        "ui/packages/core/src/utils/affordanceGrants.ts",
        "ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx"
      ],
      "open_questions": [
        {
          "question": "The door authorizes on the row's created_by; the chip decides 'mine' on the row's user_id. Both are stamped server-side from the reacting session at create, so they agree for every row made through the data door. Re-key the chip on created_by?",
          "options": ["A keep user_id (this PR): the read's grouping and the take-back key on one column, and a disagreement would only surface as a refused take-back that the settle step already rolls back with a toast", "B key the own-row map on created_by in a follow-up, leaving the reactor display on user_id"],
          "recommendation": "A, because the two columns name the same member by construction and B splits one read into two notions of 'mine'"
        },
        {
          "question": "feedReactionRemove now asks grant 'create' (crud null). The control is handed over only under feedReactionAdd, so on the records store the take-back is decided by ownership alone. Keep the row, or retire it?",
          "options": ["A keep the row with { crud: null, grant: 'create' } (this PR): no exported type moves, Clause-② stays no", "B delete the row and gate on ownership alone: narrows the exported ConsoleAffordance union, a Clause-② re-grade"],
          "recommendation": "A, because it meets the card with no contract change"
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:uipriority:p2Medium: important, M3security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions