Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

console: an approval request still links to the record when the approver has no object read (probe answers 403) — hide the link as for the two classes objectui#5211 already hides (ruling on objectstack#7497, Q5) #11878

Closed

Description

@objectstack-fleet

Filing gate: a ruling-derived card. Filed by the director seat (objectstack-ai/objectstack#12708, summon #35, session_01VYToj6PQehTEKNrjGM9akg) on the maintainer's ruling C on objectstack-ai/objectstack#7497 (「7497 C」), whose sub-question Q5 the maintainer took at the seat's recommendation: change. ⛔ Not a claim. Classes, positions and functions only.

Reader: the repo:objectui / domain:ui seat. priority:p3.

What is wrong

The honest-UI half of objectstack-ai/objectstack#7345 landed as #5211: on an approval request, the link to the record is hidden when the record probe returns no rows (the two classes where the approver cannot read the record because of private visibility or an application's row-level rule). The design round on objectstack-ai/objectstack#7497 (report 6051180080, read at the console pin a58626c8) found the third class still linked: an approver with no object-level read at all (the platform's default member permission set carries no wildcard grant), where the probe answers 403 and the link is kept by a deliberate fail-open, so the approver lands on the record page's catch-all ("may have been deleted"). That is the dead link the ruling asks to hide: the approval request stays fully usable (inbox entry, detail with the FLS-redacted snapshot, approve and reject); only the link to a page the approver cannot open goes.

Measure first

The round read the code at the pin and did not run a browser. The claimant first measures, in a browser against a booted showcase with an approver who holds no object read on the approval's target: where the record link goes today, what the record page renders, and what the probe answers (status and rows). The fix follows the measurement, not the reading.

The change

Treat a probe answering 403 as "cannot read" for the purpose of the record link, exactly as a probe answering 200 with zero rows is treated today: the link is not rendered. Nothing else about the approval request changes; no approver gains any access.

Pins (each with its ablation)

Related: objectstack-ai/objectstack#7497 (ruling C, the record on that card) · #5211 · objectstack-ai/objectstack#7345.


Generated by Claude Code

Activity

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    and removed on Oct 8, 2026
  2. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 8
    Session: session_01DrKzdPdyLLBW3qpZ4vtk7z
    Account: huangyiirene
    Branch: claude/issue-11878-approval-link-403
    Worktree: objectui-issue-11878
    Domain: domain:ui
    Seat: domain:ui#1
    File surface (line numbers on 9cb4e29), under apps/console/src/pages/system/:

    • recordReadability.ts: the probe's reading of a 403 answer (useRecordReadability about :204, isUnreadable about :245). A 403 counts as "cannot read" for the record link, exactly as a 200 with zero rows does today. Every other failure (network, 5xx, no data source) keeps today's fail-open.
    • ApprovalsInboxPage.tsx: the record link's href decision (about :701–:721), only if the 403 reading needs wiring there.
    • The tests beside these, and .changeset/11878-*.md.

    ⛔ Not on it:

    • Anything that grants or changes access. The approval request stays fully usable: the inbox entry, the detail with its FLS-redacted snapshot, approve and reject.
    • The record page's catch-all, the server's probe answer, and packages/**.
    • Seat 3's objectui#11817 (area:access, the Invitations grid).

    Any file outside this list: the dev reports it before opening the PR (stop on breach; explain in the report).
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier --repo objectstack-ai/objectui over these paths: no path-derived mandate; default tier)
    Clause-②: no
    Responsibility: objectui console approvals: an approver with no object-level read still gets a link to the record, because the readability probe treats its 403 as unknown and fails open, so the link lands on the record page's catch-all | the platform path is right: objectui#5211 already hides the link for the two other cannot-read classes from the same probe | every approver routed a request about a record their permission sets do not let them read; ruling C on objectstack-ai/objectstack#7497 (Q5: change)
    Thread-read: none
    Serial constraints cleared: none blocking.

    Why Clause-②: no: a console-internal reading of an existing probe answer. It hides a link and grants nothing. No export, prop, @object-ui/types member or accepted input changes.

  3. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 11878,
      "status": "done",
      "branch": "claude/issue-11878-approval-link-403",
      "pr": "https://github.com/objectstack-ai/objectui/pull/11901",
      "session": "session_01DrKzdPdyLLBW3qpZ4vtk7z",
      "premise_still_valid": true,
      "summary": "Measured first, then fixed. In a real browser against a booted published @objectstack 17.7.0 server, an approver with no object read (server log: find on the object not permitted for positions org_member, everyone) saw a record link on every inbox row and in the drawer; the probe's one list read answered 403 PERMISSION_DENIED, the probe swallowed it as unknown, and the link landed on the record page's 'Record not found ... may have been deleted' after two by-id reads that also answered 403. Now recordReadability.ts answers 'refused' for every id of a group whose read is refused (classifyLoadError's forbidden kind, the shared read classifier), isUnreadable covers row-set answers and refusals so the row and drawer links are withheld with no new link wiring, and every other failure stays unknown (link kept). Per the ruling's 'nothing else changes' and the order's H3 steer, objectui#8631's cause-free label now reads a new, narrower isOutsideRowSet (one line in ApprovalsInboxPage.tsx), so a refused row keeps its reference text. After the fix the same browser run shows no row or drawer link for the approver, with Approve and Reject intact; the submitter (admin, probe 200 with both rows) still gets every link.",
      "tests": "HEAD the gate union ran on: c1ea60b (fix cb4d6d2 + merge of origin/main b6f8fc8; no dependency or console-file changes came in). GATES (command → exit → verdict line): (1) os-verify-lock `pnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build` → 0 → `VERDICT command-exit 0` (Scope: 35 of 47 workspace projects, 34 built). (2) `pnpm --filter @object-ui/console type-check` → 0 (TYPECHECK_EXIT=0); `tsc --noEmit --listFilesOnly` from apps/console lists both new test files and all 14 recordReadability*/ApprovalsInboxPage* files. (3) `pnpm exec vitest run --maxWorkers=2 apps/console/` (the console project, containing all 14 readability/inbox files incl. the 2 new pin files) → 0 (VITEST_EXIT=0) → `Test Files 164 passed (164)` / `Tests 1886 passed (1886)`; 164 = git ls-files count of console test files at HEAD. (2) and (3) ran in ONE lock call sequenced with ';', so the lock printed `VERDICT batch-last-exit 0`; each part's own exit was echoed and is what is quoted. (4) the two new pin files alone before the merge → 0 → `Test Files 2 passed (2)` / `Tests 14 passed (14)`, `VERDICT command-exit 0`. (5) check:control-bytes 0 `check-control-bytes: OK`; check:new-line-citations 0 `VERDICT new-cross-file-line-citations: 0 new citation(s)`; check:changeset-claims 0 `No pending changeset names a file this change touches.`; check:pending-changeset-literals 0 `No test source names a pending changeset.`; check:vi-mock-specifiers / vi-mock-inherit / vi-mock-override-shape 0/0/0 OK; check:test-path-roots 0 OK; check:unreferenced-sources 0 `Every shipped source file in every covered package is reachable.`; check:phantom-deps 0 `Every in-scope import is declared by the package that publishes it.`; scripts/check-changeset-presence.mjs 0 (`4 source file(s) of 1 released package(s)`, 1 changeset added); scripts/check-changeset-no-major.mjs 0; control-byte self-scan grep over the 5 files: no match. Gates added beyond the order's list from the diff: vi-mock-* (new test uses vi.mock), test-path-roots, unreferenced-sources, phantom-deps (new @object-ui/react import), changeset presence/no-major. NOT MEASURED: check:eager-closure, reason: reads the console production build, not built; the new import lands in the lazily loaded approvals page and @object-ui/react is already a console dependency. LINT narrowed and measured: `eslint --format json` from apps/console over the 4 touched TS files: 4 files, 0 errors, 20 warnings; the two edited sources give 1 and 19 warnings, identical to their base versions linted over stdin (no new warnings); root eslint.config.js has no project/projectService (no type-aware linting) and no eslint-rules/ rule reads the filesystem, so the diff cannot move an untouched file's verdict; repo-wide pnpm lint is CI's. ABLATION (fix committed first, cb4d6d2): /home/user/objectstack/scripts/ablation-replace.mjs wrap mode under the verify lock, anchor `if (classifyLoadError(err) === 'forbidden') {` → `if (false && classifyLoadError(err) === 'forbidden') {`, anchor x1 → x0, replacement x0 → x1, blob 21cdf2ac1019 → 747d589254af; result `Tests 6 failed | 8 passed (14)`: all 6 refusal pins red (unit: refused for every id; refusal kept to its object. page: row link withheld, drawer link withheld, title-less refused row, decide-after-withheld), all 8 controls green; predicted red, observed red; restore: blob after == HEAD blob 21cdf2ac1019, `git diff HEAD` empty, anchor grep -c 1 / mutation grep -c 0. MEASUREMENTS (published @objectstack/* 17.7.0 installed in this run's scratchpad, `objectstack dev --fresh --seed-admin`; probe stack: one private-OWD object, a record-after-create flow whose approval node routes by a record field to an admin-created second user holding only the default positions; this branch's console under Vite; Playwright/Chromium): BEFORE on 9cb4e29 both rows linked to /apps/setup/probe_invoice/record/ID and the drawer carried the same link; the probe's single list read (top=2, select=id, filter id in both) answered 403 {error, code PERMISSION_DENIED, object}; clicking the link made two by-id reads, both 403 PERMISSION_DENIED, and the page rendered 'Record not found / The record you are looking for does not exist or may have been deleted.'; control: the admin's identical read answered 200 with both ids. AFTER: zero row links, zero drawer links, Approve/Reject present; control in browser: the admin in 'Submitted by me' got both row links and the drawer link (probe 200, 2 rows). Deep link ?request=ID landing measured 3x on the fix: one 403 probe, zero links each time. H1 CONFIRMED with shape: the real ObjectStackAdapter run live against that server rejects the probe's read with a plain Error carrying httpStatus 403 and code PERMISSION_DENIED (no status/statusCode; the client stamps both, find rethrows since it is neither a 404 nor an enable-block denial); the old bare catch dropped it as unknown. H2 CONFIRMED: the row href and the drawer branch already withheld the link on isUnreadable, so no link wiring changed. H3 MEASURED: the probe also feeds objectui#8631's cause-free label; left as the suggested route, a refusal would also switch title-less rows to 'This record cannot be opened'; kept to the link: the label reads isOutsideRowSet. What the label shows for a 403: the snapshot record_title (measured: the server snapshots it for this approver despite no object read), and for a title-less row the record id as formatIdentity prints it (as before, now unlinked); pinned. DEVIATIONS: (a) 'refused' is classifyLoadError's forbidden kind (status 403 from httpStatus/status/statusCode, or a PERMISSION_DENIED/FORBIDDEN code, or 'HTTP 403' in the message), slightly wider in theory than the order's literal 'only a 403'; chosen to reuse the shared read classifier the activity feed already uses for this same verdict rather than read a status of its own; the server sends PERMISSION_DENIED with the 403 (measured), and 401, 5xx, transport errors and the enable-block 404 are pinned as staying unknown. (b) The order's suggested route (refusal == 200-with-zero-rows everywhere) would also have changed the 8631 label; I followed the ruling text and H3's 'keep it to the link' instead, which needed the ApprovalsInboxPage.tsx wiring the claim allowed: one predicate switch in unresolvableLabelFor plus two comment lines. (c) Server: published 17.7.0 in the scratchpad, not the shared objectstack checkout (the order's route); the first two boots were refused by the stack's own author-time rules (missing 'triggers' in requires, missing sharingModel) and fixed in the probe config. (d) Commit trailers are the model-free pair per AGENTS.md, not the harness's model-named Co-Authored-By; the PR body ends with the os-dev session-URL footer. (e) Branch pushed three times (empty probe, fix, merge), counted as one push per the order. FILES_CHANGED (vs merge-base b6f8fc8): .changeset/11878-approval-link-403.md (new, @object-ui/console patch, Clause-② no); apps/console/src/pages/system/recordReadability.ts; apps/console/src/pages/system/ApprovalsInboxPage.tsx; apps/console/src/pages/system/recordReadability.refused-11878.test.ts (new); apps/console/src/pages/system/ApprovalsInboxPage.recordLinkRefused-11878.test.tsx (new). CI on #11901 at report time: in_progress (42 runs: 21 success, 3 skipped, 18 in progress, 0 failed). Servers I started (backend :4878, Vite :5878) stopped by their process groups; --fresh temp DB removed; worktree clean and left in place.",
      "mcp_calls": "0 — no MCP GitHub tool used",
      "api_writes": "2 — (1) pr_create through scripts/pm/fleet-write/dispatch.mjs: POST /repos/objectstack-ai/objectstack/dispatches (204) relayed as POST /repos/objectstack-ai/objectui/pulls → objectui#11901 draft, body read back byte-identical; (2) this os-dev-report comment through scripts/pm/post-stamped.mjs: POST /repos/objectstack-ai/objectui/issues/11878/comments. No label, assignee or PATCH writes. git push is not a REST write.",
      "open_questions": [],
      "out_of_scope_findings": [
        "class: a · reach: public door, measured in a browser: as an approver with no object read, opening a record of that object (the pre-fix inbox link) makes two by-id reads that both answer 403 PERMISSION_DENIED and the record page renders 'Record not found / The record you are looking for does not exist or may have been deleted.', a deletion reading of an object-level permission refusal · evidence: Playwright run on published 17.7.0 plus this console, screenshot and network log in the PR's measurement section · the claim fences the record page's catch-all out and the card already names it as the dead link's landing, so the seat should check whether ruling C on objectstack-ai/objectstack#7497 already disposed of it before filing · dedupe words: record page not found 403, PERMISSION_DENIED record detail, may have been deleted permission, object read refused record page",
        "carrier: 承接者:无 · noted, not filed — a title-less approval row whose object read is refused shows the record id as plain text rather than objectui#8631's cause-free label, kept so per the ruling's 'nothing else changes'; extending the label is one line (unresolvableLabelFor reading isUnreadable) plus flipping the title-less pin; a maintainer decision, not reproduced in a browser (the probe object has a title field). Recorded in the PR's Acceptance notes.",
        "carrier: 承接者:无 · noted, not filed — read-only inference: useRecordReadability marks keys attempted before its probe answers and drops a cancelled run's answer, so a target-set change while a probe is in flight could leave those keys unknown (fail-open, link kept); the deep-link landing measured 3x did not reproduce it. Recorded in the PR's Acceptance notes."
      ]
    }
  4. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Review: ACCEPT — PR objectui#11901 (head c1ea60b)

    domain:ui seat 1 · session_01DrKzdPdyLLBW3qpZ4vtk7z · reviewed against the PR's file list and diff hunks, git merge-tree, check-governed-merges.mjs, its check runs and the dev's report 6053168555, read 2026-10-08T05:42Z.

    • Shape: draft → main. The first line is Fixes #11878, and Clause-②: no starts a later line.
    • Scope: 5 files, +507/−22, inside claim 6052442006's surface. Everything is under apps/console/src/pages/system/.
      • recordReadability.ts (+103 net): the probe answers 'refused' for every id of a group whose read is refused. That is classifyLoadError's forbidden kind, the classifier the list view, the activity feed and the attachments panel already share. Every other rejection stays unknown, which keeps the link.
        • isUnreadable now covers both answers (the link).
        • A new, narrower isOutsideRowSet covers only the row-set answer (objectui#8631's cause-free label).
      • ApprovalsInboxPage.tsx (+14/−4): one predicate switch in unresolvableLabelFor, plus comment lines. The row href and the drawer already withheld the link on isUnreadable, so no link wiring changed.
      • Two new pin files, and an @object-ui/console patch changeset.
    • Clause-② re-read: an app-internal change. The new ReadabilityAnswer type and the isOutsideRowSet member are exports of a console module, not of a package entry. The new @object-ui/react import is already a declared console dependency. no holds.
    • Governed surface: 0 of 5 paths governed, 529 changed lines. That is an ordinary queue landing.
    • Done-when, item by item:
      • Approver without object read (probe 403): no record link, and ablating the 403 branch reddens it. Met. In real Chromium against a booted, published 17.7.0 server, before the fix both rows and the drawer linked to the record, and the link landed on "Record not found … may have been deleted" after two by-id reads answered 403. After the fix there are zero row and drawer links, and Approve and Reject are present. The ablation turned all 6 refusal pins red, and the 8 controls stayed green.
      • Approver with read (probe 200, rows 1): the link renders. Met. The submitter (admin) in the same browser run got every link.
      • The two classes objectui#5211 hides (probe 200, rows 0): unchanged. Met, pinned in the controls.
      • Ruling C on Design: does approver routing imply record read visibility? (#7345 model half) objectstack#7497, Q5 → change, "measure in a browser first, then change": the measurement came first (on 9cb4e29), and then the change.
    • Deviations, accepted:
      • (a) "Refused" is classifyLoadError's forbidden kind (a 403 status, or a PERMISSION_DENIED / FORBIDDEN code, or "HTTP 403" in the message). That is slightly wider than a literal 403, and it is the shared classifier, not a second one. The server sends PERMISSION_DENIED with the 403 (measured). A 401, a 5xx, a transport error and the enable-block 404 are pinned as staying unknown.
      • (b) The order's suggested route would also have moved objectui#8631's label for a refused title-less row. The dev kept the ruling's "nothing else changes" and H3's "keep it to the link", which cost the one-line predicate switch the claim allowed in ApprovalsInboxPage.tsx.
      • (c) The server was the published 17.7.0, installed in the dev's scratchpad, not the shared checkout. That is the right server for a console pinned to 17.7.0.
    • Tests: a local union on c1ea60b (with main b6f8fc8 merged in): the console project has 164 files and 1,886 tests, all green. The type-check, eslint on the touched files (no new warnings against their base versions) and the derived check:* set are green. check:eager-closure was not measured: the import lands in the lazily loaded approvals page. CI's Bundle Analysis reads it.
    • Overlap: an in-memory git merge-tree against main 5dff027 is clean.
    • CI at c1ea60b: converging at this read: 28 green, 3 expected skips, the rest running. The PR is queued when every check completes.
    • Filed from this card: objectui#11902. The record page renders "Record not found … may have been deleted" for a refused read, because RecordDetailView maps every rejection to missing. Ruling C's Q5 covers the inbox link, not the record page reached another way.
    • Noted, not filed (no defect measured):
      • A refused title-less row shows its reference text unlinked, not objectui#8631's label. That was kept by the ruling, and extending it is a maintainer call.
      • A target-set change while a probe is in flight could leave keys unknown (fail-open). That is a read-only inference, and the deep-link landing measured three times did not reproduce it.
    • PR assignee: set by this seat to huangyiirene.

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR objectui#11901 → main 230e4b2

    domain:ui seat 1 · session_01DrKzdPdyLLBW3qpZ4vtk7z · verified 2026-10-08T06:12Z.

    • Merged through the merge queue at 2026-10-08T06:12:32Z, after it was queued at 2026-10-08T05:55:59Z.
      • CI on head c1ea60b before queueing: 40 of 43 check runs passed, and the 3 skipped are the expected ones.
      • check-governed-merges.mjs: not governed, +507/−22.
    • Content check:
      • 230e4b2 is one squash commit on parent 09821b1. It changes 5 files, +507/−22, the same as the PR.
      • All 5 files are blob-identical to the reviewed head c1ea60b, and the whole diff's -U0 patch-id matches (295e96de…).
    • Card: closed completed by the PR's Fixes. pm:dispatched was removed in the same pass and read back.
    • On record:

    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:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions