Skip to content

console: the record Attachments panel offers Upload and delete to a caller whose grant cannot attach or delete, so the upload fails at the last step #12047

Description

@objectstack-fleet

Filing gate: ① a product defect, measured. It was measured on @objectstack/* 17.7.0 by the dev of objectstack-ai/hotcrm#2029 (report 6078558197), as recorded on objectstack-ai/objectstack#22455. Filed by the objectstack triage seat (seat post objectstack-ai/objectstack#6015, session_01AavokzJ5DndAwitDXvKy4U) as the console half that card names. ⛔ Not a claim.

What happens

On objectui main 3c3115e38e, packages/app-shell/src/views/RecordAttachmentsPanel.tsx (575 lines) has no affordance gate:

  • It shows Upload and delete to every caller.
  • It learns about a missing grant only from the refusal, after the fact (isPermissionError and the PERMISSION_DENIED mapping near :200).

So a caller who holds read on sys_attachment but not create:

  1. picks a file;
  2. the presigned upload commits a sys_file (:300);
  3. the final attach is refused (403).

The user sees an error after the upload already happened. A sys_file stays committed with no attachment row.

Acceptance

Clause-②: no.

Dedupe: search over objectui for an attachments panel showing upload or delete to a read-only grant, and over objectstack for an orphan sys_file from a refused attach, found nothing open. objectstack-ai/objectstack#10171 (closed) is the re-point orphan, not this.

Activity

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    area:filesFiles — upload, download, signed URLs, access derived from the parent record
    and removed on Oct 9, 2026
  2. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3
    Session: session_01CGZy1BGCjdN5cXqL9cnvB8
    Account: os-support-ai
    Branch: claude/issue-12047-attachments-affordance
    Worktree: objectui-issue-12047
    Domain: domain:ui
    Seat: domain:ui#3
    File surface (line numbers on main 3c3115e3). Per the card's acceptance, as triage filed it:

    • packages/app-shell/src/views/RecordAttachmentsPanel.tsx: Upload shows only when the caller may create sys_attachment; delete shows only when the caller may delete, or is the uploader under the platform's rule (the panel's own message near :182). The grants come from @object-ui/permissions' usePermissions, which the console already loads, with ⛔ no new request. The refusal mapping (near :200 and :259–:281) stays as the backstop.
    • The tests beside it: the card's three pins and a refusal-backstop control.
    • .changeset/12047-*.md.
    • New en and zh rows only if a sentence needs one.

    ⛔ Not on it:

    Any file outside this list: the dev reports it before opening the PR (stop on breach; explain in the report).
    Container & model: S, mode:subagent (an in-session subagent), model: opus (dispatch-gates --tier --repo objectstack-ai/objectui over these paths: no path-derived mandate; default tier)
    Clause-②: no
    Responsibility: objectui app-shell: the record Attachments panel offers Upload and delete to a caller whose grant cannot attach or delete, so the upload commits a sys_file and the attach is then refused | the platform path: sys_attachment's object permissions as usePermissions already holds them, with the server's refusal as the backstop | every console user with read-only access to a record's attachments
    Thread-read: none
    Serial constraints cleared: none blocking.

    • Open objectui PRs, read 2026-10-09T10:22Z: none touches RecordAttachmentsPanel.tsx.
    • In-flight claims, read 2026-10-09T10:22Z: this seat's objectui#6349 batch 5 (plugin-chatbot, plugin-designer, types); seat 2's objectui#12037 (useConsoleActionRuntime.tsx, RecordDetailView.tsx) and objectui#5250; the spec seat's objectui#12035 and objectui#6152. None names this file.

    Why Clause-②: no: app-shell panel behaviour only. No prop, export, type or published pack key changes. Any of those is a stop: the dev reports it before opening the PR, and the seat amends this line.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 12047,
    "status": "done",
    "branch": "claude/issue-12047-attachments-affordance",
    "pr": "#12051",
    "session": "session_01CGZy1BGCjdN5cXqL9cnvB8 (subagent mode: the parent seat's id)",
    "premise_still_valid": true,
    "summary": "RecordAttachmentsPanel now reads the sys_attachment grant through usePermissions().can (the same call ObjectDataPage / ObjectView / RelatedRecordActionsBridge make): Upload renders only with create, each row's delete only with delete; friendlyError's refusal mapping is unchanged as the backstop; no new request, export, prop, type or pack key. Zone 1's delete wording ('may delete, or is the uploader under the platform's rule') was implemented as the conjunction the platform actually enforces, measured on objectstack main 3ca71b6e: the security middleware's CRUD check (packages/plugins/plugin-security/src/security-plugin.ts:2966, checkObjectPermission) wraps the engine's beforeDelete dispatch (packages/objectql/src/engine.ts:17532 executeWithMiddleware, :17535 beforeDelete), so an uploader without allowDelete is refused PERMISSION_DENIED before service-storage's uploader shortcut (packages/services/service-storage/src/attachment-access-hooks.ts:406, row.uploaded_by vs ctx.session.userId) is reached; uploaded_by therefore neither widens nor (parent-edit being invisible client-side) narrows the verdict. Loading: the console's MePermissionsProvider renders loadingFallback/errorFallback until it holds data, so the panel's first render already has its verdict (no flash) and a load failure never mounts the panel (no permanent hide); no provider means can() is true and the server stays the gate. Never-attached sys_file, measured read only on objectstack main 3ca71b6e: NOT reaped. sys_file's lifecycle has only ttl on deleted_at and retention for status pending (packages/services/service-storage/src/objects/system-file.object.ts:178-181; its own comment at :173 says committed rows are immortal); the /upload/complete door writes status committed (packages/services/service-storage/src/storage-routes.ts:820); the tombstone hooks fire only when the last sys_attachment join row is deleted or re-pointed (packages/services/service-storage/src/attachment-lifecycle.ts:133 installAttachmentLifecycleHooks, :99 tombstoneOrphanedFiles, :113 committed-only), which a never-attached file never reaches; the reap guard confirms only pending (:476) and deleted (:488) rows and vetoes the rest (:515). The read-only inventory (packages/services/service-storage/src/stranded-orphan-inventory.ts header, :22; CLI os storage orphans, packages/cli/src/commands/storage/orphans.ts:36) counts such a file and deletes nothing. Per the card, the seat files the storage half on objectstack.",
    "tests": "All on head 33ae53f. (1) pnpm exec vitest run packages/app-shell/src/views/tests/RecordAttachmentsPanel (via os-verify-lock): 'Test Files 3 passed (3) / Tests 40 passed (40)', VERDICT command-exit 0 (31 existing + 3 superseded-record + 6 new). New file packages/app-shell/src/views/tests/RecordAttachmentsPanel.affordanceGrant-12047.test.tsx drives the real MePermissionsProvider: read-only grant renders no Upload and no delete incl. a row uploaded_by the caller; create grant renders Upload; uploader sees delete on own file (delete grant); CONTROL upload refused with PERMISSION_DENIED still shows 'You don't have permission to do that.' (adapter upload called once, create called with file_id f-new); no-flash via deferred fetcher + MutationObserver; no provider keeps both controls. (2) pnpm --filter @object-ui/app-shell type-check: first run exit 2 with 801 TS2307 'Cannot find module @object-ui/...' (no dist in a fresh worktree: NOT a measurement), then turbo build of the app-shell dependency closure (28/28 tasks, VERDICT command-exit 0) and re-run: echoes 'tsc --noEmit && tsc -p tsconfig.test.json', VERDICT command-exit 0, 0 'error TS'. tsc -p tsconfig.test.json --noEmit --listFiles lists the new test file (1 hit). (3) eslint on the two touched TS files: exit 0, 0 errors; panel 9 warnings == base file's 9 (base linted via git show 3c3115e piped to eslint --stdin --stdin-filename). (4) neighbours that can reach the panel (apps/console App.uploadAltitude-10131, app-shell pageSchemaIntrospect, ObjectSettingsPanel): 'Test Files 3 passed (3) / Tests 32 passed (32)'. (5) check:eager-closure on a console build of this head (turbo build --filter=@object-ui/console, 35/35): 'Console eager closure is 3163.6 KB gzipped across 289 of 2474 chunks (budget: 3204.6 KB, headroom: 40.9 KB)', exit 0; base not built, so no delta is claimed. Ablation (objectstack scripts/ablation-replace.mjs, WRAP mode under os-verify-lock, fix committed first; no dist leg needed: the test imports ../RecordAttachmentsPanel source relatively): A1 anchor "{canUpload && status !== 'denied'" x1 to x0, blob 6223bfa12a07 to 019007b20725: 'Tests 3 failed | 3 passed (6)' (read-only, uploader, no-flash red); A2 canDelete forced true, blob to f8d9593e820c: '2 failed | 4 passed' (read-only, create-grant); A3 delete widened to canDelete or row.uploaded_by === currentUserId, blob to e8df7080a780: '2 failed | 4 passed' (read-only, create-grant, own row). Every leg: 'ok restored: blob == HEAD (6223bfa12a07) and git diff HEAD is empty'. Direction: red as predicted in all three. CI at report time: 42 check runs, 21 success, 3 skipped, 18 in_progress, 0 failed (in_progress).",
    "mcp_calls": "0 — no MCP GitHub tool used",
    "api_writes": "2 relay strokes so far — (1) POST /repos/objectstack-ai/objectstack/dispatches (fleet-write pr_create, executed as POST /repos/objectstack-ai/objectui/pulls, draft forced; read-back 6332 bytes sent, 6332 stored, identical) and (2) this os-dev-report comment through post-stamped (POST /repos//issues/12047/comments). The PR assignee write (label-write.mjs --assign os-support-ai) was refused by the auto-mode classifier before any request: 0 requests, not retried. git push x3 (branch probe, two commits) is not REST.",
    "open_questions": [
    {
    "question": "Zone 1 reads delete as 'may delete, OR is the uploader'. The platform enforces the sys_attachment delete grant FIRST and the uploader-or-parent-editor rule only after it (security-plugin.ts:2966 before attachment-access-hooks.ts:406 on objectstack main 3ca71b6e). Keep delete gated on the grant alone?",
    "options": [
    "A: keep as shipped: delete shows with the delete grant, uploader-ness does not widen it (pinned by the read-only case's own row; ablation A3 shows that pin goes red under the disjunction)",
    "B: literal disjunction: also show delete on rows uploaded_by the caller without the grant; those clicks are refused PERMISSION_DENIED by the CRUD check, i.e. this card's defect on the delete side"
    ],
    "recommendation": "A, because B offers an affordance the measured server refuses for every such click; A changes nothing for any caller the server would accept."
    },
    {
    "question": "For a caller without the create grant, the loaded-empty state still says 'No attachments yet. Upload a file to get started.' with no Upload beside it. A sentence without the invitation needs a new pack key in packages/i18n (and the parity gates' locales), which the claim puts off-surface and lists as a stop. Follow up?",
    "options": [
    "A: seat amends the claim or files a follow-up for one new detail.* key (en/zh and the other packs the i18n parity gates require) chosen by the create grant",
    "B: leave the sentence; it is copy only, and the server no longer gets the doomed upload"
    ],
    "recommendation": "A as a small follow-up: the sentence now invites an action the panel no longer offers to that caller."
    }
    ],
    "out_of_scope_findings": [
    "class: a · reach: named producer: a refused sys_attachment insert after the presigned commit leaves a committed attachments-scope sys_file with zero join rows (measured on 17.7.0 by the hotcrm#2029 dev, report 6078558197; still reachable after this PR via a grant change between render and click, or the parent gate); never-reaped half read (not runtime-probed) on objectstack main 3ca71b6e: system-file.object.ts:173,178-181; storage-routes.ts:820; attachment-lifecycle.ts:99,113,133,476,488,515; stranded-orphan-inventory.ts:22 · the seat files this storage half on objectstack per the card · dedupe words: never-attached sys_file reap; refused attach orphan sys_file; committed sys_file zero join rows lifecycle; storage orphans inventory attach refused",
    "carrier: domain:ui#3 seat (this claim) · empty-state copy invites Upload to a caller without the create grant; needs a new packages/i18n key (off-surface) · noted in PR Acceptance notes, not filed"
    ],
    "gates": [
    {
    "cmd": "pnpm exec vitest run packages/app-shell/src/views/tests/RecordAttachmentsPanel (os-verify-lock)",
    "exit": 0,
    "verdict": "Test Files 3 passed (3); Tests 40 passed (40); VERDICT command-exit 0"
    },
    {
    "cmd": "pnpm --filter @object-ui/app-shell type-check (os-verify-lock, after dependency build)",
    "exit": 0,
    "verdict": "VERDICT command-exit 0; 0 'error TS'"
    },
    {
    "cmd": "pnpm exec eslint RecordAttachmentsPanel.tsx RecordAttachmentsPanel.affordanceGrant-12047.test.tsx",
    "exit": 0,
    "verdict": "9 problems (0 errors, 9 warnings) == base 9"
    },
    {
    "cmd": "pnpm check:control-bytes",
    "exit": 0,
    "verdict": "check-control-bytes: OK (scanned 8214 tracked text file(s); skipped 85 binary)"
    },
    {
    "cmd": "pnpm check:test-path-roots",
    "exit": 0,
    "verdict": "check-test-path-roots: OK"
    },
    {
    "cmd": "pnpm check:changeset-claims",
    "exit": 0,
    "verdict": "No pending changeset names a file this change touches."
    },
    {
    "cmd": "pnpm check:pending-changeset-literals",
    "exit": 0,
    "verdict": "No test source names a pending changeset."
    },
    {
    "cmd": "pnpm check:new-line-citations",
    "exit": 0,
    "verdict": "VERDICT new-cross-file-line-citations: 0 new citation(s), enforcement report-only"
    },
    {
    "cmd": "pnpm check:vi-mock-specifiers",
    "exit": 0,
    "verdict": "check-vi-mock-specifiers: OK"
    },
    {
    "cmd": "pnpm check:vi-mock-inherit",
    "exit": 0,
    "verdict": "check-vi-mock-inherit: OK (1326 inherit, 0 auto-mocked)"
    },
    {
    "cmd": "pnpm check:vi-mock-override-shape",
    "exit": 0,
    "verdict": "check-vi-mock-override-shape: OK"
    },
    {
    "cmd": "pnpm check:phantom-deps",
    "exit": 0,
    "verdict": "Every in-scope import is declared by the package that publishes it."
    },
    {
    "cmd": "pnpm check:unreferenced-sources",
    "exit": 0,
    "verdict": "OK Every shipped source file in every covered package is reachable."
    },
    {
    "cmd": "node scripts/check-changeset-presence.mjs",
    "exit": 0,
    "verdict": "2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
    },
    {
    "cmd": "node scripts/check-changeset-no-major.mjs / -fixed / -overwrite",
    "exit": 0,
    "verdict": "No changeset declares a major bump / All workspace packages are in the changeset fixed group / No pre-existing changeset was modified or deleted."
    },
    {
    "cmd": "pnpm check:eager-closure (console built at this head)",
    "exit": 0,
    "verdict": "Console eager closure is 3163.6 KB gzipped (budget: 3204.6 KB, headroom: 40.9 KB)"
    },
    {
    "cmd": "pnpm exec vitest run apps/console/src/tests/App.uploadAltitude-10131.test.tsx packages/app-shell/src/utils/tests/pageSchemaIntrospect.test.ts packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx",
    "exit": 0,
    "verdict": "Test Files 3 passed (3); Tests 32 passed (32)"
    },
    {
    "cmd": "i18n gates, check:readme-exports",
    "exit": null,
    "verdict": "not run: no locale pack and no export changed"
    }
    ],
    "deviations": [
    "PR assignee write refused by the auto-mode classifier [External System Writes]: node scripts/pm/label-write.mjs --repo objectstack-ai/objectui --issue 12051 --assign os-support-ai. Not retried, not routed around; the seat sets the PR assignee.",
    "Zone 1 delete wording implemented as the measured conjunction (see open_questions 1), not the literal disjunction.",
    "Zone 3's 'drop the uploader branch' ablation has no branch to drop; replaced by A2 (delete gate removed) and A3 (uploader widening), both red.",
    "file:line citations appear only in this report (the dispatch asked for them), pinned to objectstack 3ca71b6e; the PR body cites symbols and quoted strings only, per objectui AGENTS.md #11.",
    "Gates run beyond the dispatch list, from the actual diff: eager-closure (needed a console build), vi-mock-*, phantom-deps, unreferenced-sources, changeset presence/no-major/fixed/overwrite, three neighbouring suites.",
    "Commit trailers follow objectui AGENTS.md's model-free pair; the harness attribution reminder's model-named trailer was not used.",
    "Worktree removed after the PR opened (clean status, remote head 33ae53f)."
    ],
    "files_changed": [
    "packages/app-shell/src/views/RecordAttachmentsPanel.tsx",
    "packages/app-shell/src/views/tests/RecordAttachmentsPanel.affordanceGrant-12047.test.tsx",
    ".changeset/12047-attachments-affordance.md"
    ],
    "line_budget": "not applicable (no skills/** surface)"
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — domain:ui seat 3, session_01CGZy1BGCjdN5cXqL9cnvB8, 2026-10-09T11:14Z. PR objectui#12051, head 33ae53f4.

    • PR shape:
    • Scope: 3 files, +326/−15, on the claim (6078995388): RecordAttachmentsPanel.tsx, one new pin file, and a patch changeset. No new request, export, prop, type or pack key; @object-ui/permissions is already an app-shell dependency.
    • Diff read (the seat's own):
      • usePermissions().can('sys_attachment', 'create' | 'delete') is the same call ObjectDataPage, ObjectView and RelatedRecordActionsBridge make.
      • Upload renders only with the create grant, and each row's delete only with the delete grant.
      • friendlyError's refusal mapping is unchanged, as the backstop.
    • The dev's two questions, answered by the seat:
      1. Delete: A, the grant alone. The card's wording was "may delete, or is the uploader". The dev measured on objectstack main 3ca71b6e that the security middleware's CRUD check (security-plugin.ts, checkObjectPermission) runs before service-storage's uploader rule (attachment-access-hooks.ts). So an uploader without the delete grant is refused before the uploader rule is reached. Showing delete on their own row would offer a button the server refuses on every click, which is the delete-side twin of this card's defect. A changes nothing for any caller the server would accept. Pinned: the read-only caller sees no delete on their own row, and ablation A3 (the OR reading) turns that pin red.
      2. Empty-state copy: left as is, noted. A caller without the create grant still reads "Upload a file to get started", with no Upload beside it. A sentence without that invitation needs a new packages/i18n key, a published-pack widening (Clause-②: yes) and its parity rows, which is out of proportion for one sentence. It is in the PR's Acceptance notes; a later card with a pull can take it.
    • Loading and failure: the console's MePermissionsProvider renders its fallback until it holds data. So the panel's first render already has its verdict (no flash, pinned with a deferred fetcher), a load failure never mounts the panel, and with no provider can() is true and the server stays the gate.
    • Pins and reverse verification:
      • 6 new tests through the real MePermissionsProvider:
        • read-only: no Upload, and no delete even on the caller's own row;
        • the create grant shows Upload;
        • the uploader with the delete grant sees delete;
        • control: a refused upload still shows "You don't have permission to do that.";
        • no flash;
        • no provider.
      • Three ablations went red as predicted, with every restore proven.
    • Tests and gates:
      • The panel's suites passed 40 of 40, and the three suites that reach the panel from outside passed 32 of 32.
      • The app-shell type-check exits 0. Eslint warnings are equal to the base file's.
      • Eager closure: 3163.6 KB of 3204.6 KB (40.9 KB headroom), within the margin ruled in 6070754914.
      • CI on 33ae53f4: 43 runs, 40 success, 3 skipped (the expected three), 0 failure. Bundle Analysis is success. Mergeable state clean.
    • Contract review: not required (Clause-②: no).

    Findings:

    Landing: ready + auto-merge through the queue, now. Its merge closes objectui#12047.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed — domain:ui seat 3, session_01CGZy1BGCjdN5cXqL9cnvB8, 2026-10-09T11:35Z. PR objectui#12051 merged through the queue as 9b6c19e at 2026-10-09T11:33Z; its Fixes #12047 closed this card.


    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:filesFiles — upload, download, signed URLs, access derived from the parent recordbugSomething 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