Repository navigation
finding: an approver routed a request can be unable to open the record it concerns — the inbox's record link dead-ends on "Record not found" #5211
Description
Moved from
objectstack-ai/objectstack#7345by therepo:objectuiPM seat (sessionsession_01RV6yuVCxymHYE16PL9vQkE) under the maintainer's direct-dispatch authorisation, 2026-08-18, verbatim: 「转」.Reason: file-at-destination. Dispositions 2 and 3 — the ones that are actually polish rather than security changes — both land in this repo (the inbox row's link affordance, and the record page's "not found" vs "not visible to you" message). Disposition 4 is explicitly flagged in the body as a security-model change, not a UX fix, and if it is ever taken it must go back to the platform repo as a contract-first split, not be done here.
⚠️ The original carried anapprovalslabel that does not exist in this repo. ⛔ Not minted — recorded here in prose instead.Original thread has 2 comments; read them there. Bare issue references below rewritten to full cross-repo form.
Observation-class finding recorded during the objectstack-ai/objectstack#7331 browser verification run. Filed unassigned, no pm:queue — for triage to grade. It is not a routing regression from objectstack-ai/objectstack#7213: the routing half was verified correct and is stated below so the next reader does not re-derive it.
What happens
A non-admin who is a pending approver opens the Approvals Inbox, sees the request, and clicks the record chip in its row (INV-1002). The record page renders "Record not found — The record you are looking for does not exist or may have been deleted."
The record exists and the request is live.
The routing is correct — that part was ruled out first
The rendered href is exactly what objectstack-ai/objectstack#7213 intended: it carries the current app's segment, the right object and the right record id.
INV-1002 -> /_console/apps/com.objectstack.account/showcase_invoice/record/9oAya50sd_E-PEWH
Loading the same URL shape under the same app with a record the persona can read renders the record page normally (verified with showcase_project/record/1YtAy4jz7Wsi9m9k under com.objectstack.account). So the account app can host a record page for a foreign business object; nothing about the app segment is wrong.
The real cause: record-level visibility
The authoritative server answer for that persona is a 404, not a 403:
GET /api/v1/data/showcase_invoice/9oAya50sd_E-PEWH
404 {"error":"Record 9oAya50sd_E-PEWH not found in showcase_invoice","code":"RECORD_NOT_FOUND"}
The persona holds contributor, whose showcase_invoice grant is owner-scoped; the invoice is owned by another user, so it is filtered out of the row set before the by-id read. The same read as admin returns the record. Transferring owner_id to the persona did not change the outcome either (the seeded showcase rows carry organization_id: null), so the showcase fixture makes this easy to hit — but the general shape is not showcase-specific: approver resolution routes on positions, record visibility is a separate gate, and nothing reconciles the two.
Why it is worth recording rather than shrugging at
The drawer degrades well: it carries the request's payload snapshot (record title, object, subtotal, account, status), so an approver can decide without opening the record, and the decision itself works — the run approved this exact request from the drawer successfully. So this is not a blocked approval.
What is unsatisfying is the affordance: the inbox offers a record link that, for the person it is offered to, leads to a page saying the record may have been deleted. The message is also the least useful of the available truths — the honest one is "you do not have access to this record", which the platform renders elsewhere (sys_inbox_message renders exactly that for the same persona).
Candidate dispositions (not decided here)
- Leave it. Approvals decide from the snapshot by design; record access is the security model's business. Cheapest, and defensible.
- Suppress the record link in an inbox row when the viewer cannot read the target — needs a per-row readability probe the inbox does not do today.
- Keep the link and fix only the landing message: distinguish "not visible to you" from "does not exist" on the record page. Smallest user-facing improvement, no security change.
- Grant an approver implicit read on the record under approval for the life of the request. The largest surface by far — it makes approver routing a source of record visibility, which is a real security decision and should not be taken as a UX fix.
No option recommended; 4 in particular is a security-model change, not a polish item.
Evidence
Measured 2026-08-10, examples/app-showcase, framework 88154bee1, console dist stamp 8aad9fd50b16750aebdd294392ff79540f17cd32. Non-admin persona holding contributor + finance. Screenshots of the "Record not found" landing, of the same URL shape rendering a readable record, and the paired API reads were captured in the run.
Activity
os-support-ai commentedon Aug 18, 2026
pm:queue→needs-user-decision. PM round 6, sessionsession_01RV6yuVCxymHYE16PL9vQkE. Not dispatched — and I am correcting a label I set myself.First, my own error in the transfer
This card's body says, verbatim: "Filed unassigned, no
pm:queue— for triage to grade." When I rebuilt it here fromobjectstack-ai/objectstack#7345earlier today I attachedpm:queueanyway. That is a half-state of my own making: the body says one thing, the label says another, and there is no triage grading comment on it (comment count: zero). Corrected now.Why it is not dispatchable, beyond the missing grading
Four dispositions, no option recommended, and option 1 ("leave it") is explicitly defensible in the card's own words. That alone makes it a decision. But there is a sharper reason I want on the record before anyone picks this up, because disposition 3 reads like free polish and is not.
Disposition 3 — "distinguish not visible to you from does not exist on the record page" — partially undoes a deliberate server-side choice.
The card's own evidence shows the server answering:
404, not 403 — and that is not an accident of implementation. The record is filtered out of the row set before the by-id read, so from the server's perspective it does not exist for this principal. Returning 404 rather than 403 is the standard way to avoid confirming that a record exists to someone not permitted to see it.
So changing the UI to say "you do not have access to this record" would tell the viewer the record exists — which the server deliberately declined to tell them. That is an information-disclosure decision, not a copy fix. It may well be the right call here (an approver already knows the record exists — they were routed a request about it, and the drawer shows them its payload snapshot), but that argument is exactly the kind that has to be made explicitly rather than assumed by a dev polishing a message.
The card notes
sys_inbox_messagerenders the honest message for the same persona. Worth knowing — but it is evidence that the two surfaces disagree, not evidence about which one is right.This lands squarely on the maintainer floor: 安全/权限边界 — a "fix" to access-control messaging is a disguised product decision.
Where each disposition lands
objectstack-ai/objectstackas a contract-first split, ⛔ not done here.No PM recommendation. I could argue 2, or 1, or 3-with-the-approver-exception, and the choice turns on how much existence-disclosure an approver is entitled to — which is yours, not mine.
Not a blocked approval, for calibration
The drawer carries the request's payload snapshot (record title, object, subtotal, account, status) and the decision itself works — the verification run approved this exact request from the drawer. What is broken is an affordance: the inbox offers a link that, for the person it is offered to, lands on a page saying the record may have been deleted. Real, worth fixing, not urgent.
Generated by Claude Code
os-support-ai commentedon Aug 19, 2026
Claim: PM loop round 17⚠️ It is adjacent to a security boundary, which is why the ruling is shaped the way it is: the server's semantics stay untouched.
Session:
session_01RV6yuVCxymHYE16PL9vQkEBranch:
claude/issue-5211-inbox-record-link-readabilityWorktree:
objectui-issue-5211Domain:
repo:objectui(execution seat)File surface: the Approvals Inbox row that renders the record link, and whatever readability probe it needs. The card names the surface by behaviour, not by path — locating it is your first deliverable; declare the exact files in your report. ⛔ OUT:
packages/plugin-grid/src/ObjectGrid.tsx+packages/react/src/spec-bridge/bridges/list-view.ts(#5068, PR5345 open),packages/layout/**+packages/i18n/**(#5197, in flight),packages/cli/**(#5127, PR5334 awaiting the maintainer),scripts/check-doc-snippet-types.mjs(#5174, just merged — rebase).Container & model:
M,mode:subagent,model: opus— the render change is small; the probe's cost measurement decides which branch ships.Clause-②: no — no schema accept/reject boundary moves and no public surface widens.
Serial constraints cleared: nothing open touches the approvals inbox.
Zone 1 — RULED, both branches pre-ruled. Do not escalate.
Maintainer, 2026-08-19, verbatim 「全部接受」, recorded on this card:
⛔ The two rejected options, and why option 3 is the dangerous one
Option 3 — changing the landing message to "you do not have access to this record" — is rejected, and it is the one you will be tempted by because it is a one-line copy change. It is not copy polish. The server answers:
404, not 403 — deliberately. The record is filtered out of the row set before the by-id read, so from the server's perspective it does not exist for this principal. Saying "you do not have access" would tell the viewer the record exists, which the server declined to tell them. That is an information-disclosure decision, and it has been made: no.
Option 4 (an implicit read grant for approvers) is rejected too, and if it ever comes back it goes to
objectstack-ai/objectstackas a contract-first split — ⛔ never here.Zone 2 — the cost measurement is a deliverable, and it selects the branch
The ruling hands you the fallback so you do not have to come back and ask. Measure the probe, then pick:
Report the measurement either way — what the probe costs, how many calls, on what render. ⛔ Do not silently choose the cheaper code path; the ruling asks for the number, and "I judged it too costly" without one is not a report.
What is NOT broken — calibration, so you don't over-fix
The approval itself works. The drawer carries the request's payload snapshot (record title, object, subtotal, account, status) and a previous verification run approved this exact request from the drawer. What is broken is an affordance: the inbox offers a link that, for the person it is offered to, lands on a page saying the record may have been deleted. Real, worth fixing, not urgent — so ⛔ do not grow this into a rework of the inbox.
Note also that
sys_inbox_messagerenders the honest message for the same persona. That is evidence the two surfaces disagree — it is not evidence about which one is right, and ⛔ it is not licence to make this surface match it (that would be option 3).Tests
If you ship Option 1 instead: pin the drawer's new wording and say plainly in the report that the link behaviour is unchanged by design.
Reverse-verify: restore the row change, keep the tests, predict which go red before running, report predicted vs observed. State per leg whether a build artifact sits between the edit and the thing under test.
Gates
Derive from your diff (
scripts/pm/dispatch-gates.mjsis objectstack-only). Run vitest from the repo root — the reason is not that package-scoped runs are refused (17 packages own a standalonevitest.config.tsand are not refused) but that a package-scoped run uses a different config than CI does. If your change adds user-visible copy, expectcheck:i18n-keys/check:i18n-driftand all ten locale packs. Changeset percheck-changeset-presence.mjs's verdict;patchorminor, ⛔ nevermajor.⛔ Do not background a long check and end your turn. ⛔ No exhaustive paginated API sweeps.
Deliver
Draft PR against
main, first body lineFixes #5211. State which branch you shipped and the measurement that chose it, in the first paragraph of the PR body. Push the branch early. Return the JSON report and post it as a comment whose first line is the literal<!-- os-dev-report -->marker.Generated by Claude Code
fix(console): suppress the Approvals Inbox record link when the viewe…
os-support-ai commentedon Aug 19, 2026
{ "issue": 5211, "status": "done", "branch": "claude/issue-5211-inbox-record-link-readability", "pr": "https://github.com/objectstack-ai/objectui/pull/5347", "premise_still_valid": true, "summary": "Shipped Option 2, the primary ruling: the Approvals Inbox row suppresses the record link when the viewer cannot read the target, and so does the drawer's record-title link (same link, same record, same viewer — fixing only the row would move the dead end one click deeper). The 'Edit record' button on a returned request is deliberately NOT suppressed and carries a comment saying why: that branch renders only for the submitter, a different persona. File surface, located by behaviour as instructed: apps/console/src/pages/system/ApprovalsInboxPage.tsx (RecordCell + drawer title link) plus a new apps/console/src/pages/system/recordReadability.ts holding the probe. The server's 404-not-403 semantics are untouched and unreported: no copy is added anywhere (so no i18n keys and no locale packs), nothing says why a target is unreadable, and no test mentions 404-vs-403. The probe fails open — unknown, failed or no data source leaves the link exactly as today, because this is an affordance and the server stays the only authority. One note for the maintainer, flagged rather than decided silently and NOT an escalation: the ruling's 'expensive' clause also names 'a call the inbox does not otherwise make', and this IS a new call type (the inbox otherwise talks only to /approvals/*). I read that clause as describing per-row cost, because on the strict reading no probe could ever qualify, which would make Option 2 unreachable while the ruling names it primary and calls it batchable. Option 1 remains a small revert of two render sites if that reading is preferred.", "measurement": "Option 2 selected on this number: ONE batched list read per distinct object per load, ZERO per-row round trips. Asserted, not estimated. Render-level (ApprovalsInboxPage.recordLink.test.tsx): 2 rows sharing one object => exactly 1 adapter.find call, pinned by expect(adapterFind).toHaveBeenCalledTimes(1) plus the exact params. Plan-level (recordReadability.test.ts): 50 rows / 2 objects => 2 calls; 50 rows / 1 object => 1 call; duplicate targets deduped to 1; a single object splits only past 100 ids. Each (object,id) is probed at most once per mount, so paging in another 50 rows costs only the new ids; the drawer costs 0 extra for a row already in the list and at most 1 for a deep-linked request naming an uncovered object. For scale, the inbox already spends 1 listRequests per load plus 1 badge refresh on the non-pending tabs — the probe is the same order of traffic, not a multiple of the row count.", "tests": "All runs from the REPO ROOT via `pnpm exec vitest run <paths>` (the config CI uses), each wrapped in the shared verify lock. (1) New coverage: `pnpm exec vitest run apps/console/src/pages/system/recordReadability.test.ts apps/console/src/pages/system/ApprovalsInboxPage.recordLink.test.tsx` => 'Test Files 2 passed (2) / Tests 14 passed (14)'. (2) Affected package: `pnpm exec vitest run apps/console/` => 'Test Files 57 passed (57) / Tests 672 passed (672)'. (3) apps/console `type-check` => exit 0 (script name echoed in the output, so not the zero-match trap); it FIRST needed `pnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build`, because without the built dist declarations every @object-ui import reads as an unresolved module and the run is a false red. (4) apps/console `lint` => 0 errors (201 pre-existing warnings; none on the new files). (5) Gates derived from the diff, all green: check:control-bytes, check:i18n-keys, check:i18n-drift, check:i18n-dead-keys, check:phantom-deps, check:self-import, check-changeset-presence ('1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'), check-changeset-no-major. Union run at head d8927ba1a, working tree byte-identical to HEAD. REVERSE VERIFICATION, predicted before running: restore only the page render change, keep the probe module and all tests => probe unit file all green; suppression RED; cost RED; the pure decision-path pin GREEN; suppressed-row-decidable RED; fail-open RED. OBSERVED EXACTLY THAT: 'Tests 4 failed | 10 passed (14)'. Direction was straight red — not a diagnostic-count change, not a reversal. Restore leg: `git status --porcelain` empty against HEAD, back to 14/14. NO BUILD ARTIFACT sits between the edit and the thing under test on either leg — the root Vitest config aliases every @object-ui specifier at that package's src, and the page and probe are apps/console source Vite transforms directly, so there was no dist to rebuild for the ablation; the tsc type-check is the one tool here that does read built dist declarations, and its closure build is reported above. HONESTY NOTE: my first decision-path pin was WRONG and I fixed it. It opened by waiting for the suppressed link to be absent, which passes vacuously while the page is still a loading skeleton — measured green on the pre-change tree, proving nothing. It is now split: one case pins inline approve without involving the probe at all (green on BOTH trees, which is what makes it a pin), and one waits for rows first, then for the link to vanish, then decides the suppressed row.", "open_questions": [], "out_of_scope_findings": [ "filed as #5348: ApprovalsInboxPage declares RequestCell/RecordCell/InlineActions inside its own component body, so every render produces a new component type and React remounts those cells — measured as a silently swallowed inline-Approve click when a re-render landed mid-interaction and the rest of the pointer sequence went to a detached node. Unassigned, `finding` label, no `pm:queue`. Not fixed in this PR." ] }Generated by Claude Code
fix(console): suppress the Approvals Inbox record link when the viewe…
fix(console): stop the Approvals Inbox remounting every row cell on e…