You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
RecordAttachmentsPanel offers a Retry for an api-disabled sys_attachment, which no retry can fix #4693
Observation-class, found while implementing #4684 (the unavailable-vs-empty split in RecordAttachmentsPanel). That card's scope was the assert-vs-don't-assert distinction — stop claiming "No attachments yet" for a read that never answered — and it is fully served without this refinement, so this is filed rather than widened into that PR.
After #4684 the panel classifies a failed sys_attachment list read two ways: denied (403 / PERMISSION_DENIED / FORBIDDEN / an RLS denial, via isPermissionError) and unavailable (everything else). unavailable renders "We couldn't load the attachments for this record." with a Retry, which is the right affordance for what it was designed against: a network failure, a 5xx, an expired session. All three are transient and a second attempt genuinely fixes them.
Two error codes land in unavailable that are not transient:
OBJECT_API_DISABLED (404) — enable.apiEnabled: false; the object is not exposed over the data API at all.
OBJECT_API_METHOD_NOT_ALLOWED (405) — the operation is absent from the enable.apiMethods whitelist.
Both are pure functions of the object's metadata — no user, no session, no request body — so, as packages/data-objectstack/src/index.ts puts it, "every retry of every persona gets the identical answer". The panel offers a Retry button that is guaranteed to re-render the same state, which is the same class of wrong advice (telling the user to try again when nothing will change) that ListView.classifyLoadError exists to stop giving. That classifier already separates the case as its own api-disabled kind, precisely because neither "try again" nor "ask your administrator for access" is honest for it.
Why this is an observation, not a defect
The panel is not lying about anything after #4684 — unavailable correctly asserts nothing about the record's contents, which was the actual bug. What remains is copy and affordance precision: a useless button and a sentence less specific than the sibling surface's. Severity is also bounded by reachability — the panel only renders when the object declares enable: { files: true }, and sys_attachment is a system object, so an enable-block denial reaching it is an unusual deployment.
Recording it because the judgement was made deliberately during #4684 rather than overlooked, and the reasoning is worth more written down than re-derived.
Suggested shape
Either reuse the sibling classification wholesale (ListView.classifyLoadError is currently a module-local function in packages/plugin-list/src/ListView.tsx, not exported — lifting it to a shared home would serve both surfaces and is arguably the better half of this), or add a fifth panel status that drops the Retry and says the attachments list is not available on this object. The predicate already exists and is exported: isApiAccessDeniedError in @object-ui/data-objectstack.
Backlink: found during the #4684 dispatch; PR #4691 carries the note in its body. Neither is addressed by this issue, and both are tracked on their own.
First-touch grading (seat repo:objectui; session session_01RnQd8iMMUwXQEV1crFmQiQ, 2026-08-15): pm:queue, size S–M, age-ordered / low priority (bounded reachability as the card itself argues: enable.files gating + system object). The dispatch, when it rides, should take the card's own "better half": lift ListView.classifyLoadError out of its module-local scope into a shared home so both surfaces consume ONE classifier (the isApiAccessDeniedError predicate is already exported), and give the panel the no-retry copy for the api-disabled kind — rather than growing a panel-local fifth state by hand. The deliberate-judgment record is exactly what findings are for; good filing.
Scope: the full card — stop offering Retry in RecordAttachmentsPanel for OBJECT_API_DISABLED / OBJECT_API_METHOD_NOT_ALLOWED (metadata-determined, retry-invariant), preferring the lift-the-classifier half of the card's suggested shape (ListView.classifyLoadError → a shared home both surfaces consume), with isApiAccessDeniedError from @object-ui/data-objectstack as the predicate. PR will be Fixes #4693.
{
"issue": 4693,
"status": "done",
"branch": "claude/issue-4693-api-disabled-attachments",
"pr": "https://github.com/objectstack-ai/objectui/pull/4707",
"pr_number": 4707,
"pr_url": "https://github.com/objectstack-ai/objectui/pull/4707",
"head_sha": "7e1701bbdb2bab4063eedf801bde21a743098c03",
"premise_still_valid": true,
"premise_checks": [
{ "assumption": "classifyLoadError was a module-local, unexported function in packages/plugin-list/src/ListView.tsx with an independent api-disabled kind", "held": true },
{ "assumption": "isApiAccessDeniedError is exported from @object-ui/data-objectstack and covers OBJECT_API_DISABLED + OBJECT_API_METHOD_NOT_ALLOWED", "held": true, "note": "also found API_ACCESS_DENIED_CODES already exported there, independently duplicating ListView's own local Set of the same two codes" },
{ "assumption": "post-#4684/#4691 RecordAttachmentsPanel has a four-state loading/loaded/denied/unavailable model, denied via isPermissionError", "held": true }
],
"changed_files": [
"packages/react/src/utils/error-message.ts",
"packages/react/src/utils/error-message.test.ts",
"packages/react/src/index.ts",
"packages/plugin-list/src/ListView.tsx",
"packages/app-shell/src/views/RecordAttachmentsPanel.tsx",
"packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx",
"packages/i18n/src/locales/en.ts",
"packages/i18n/src/locales/zh.ts",
"packages/i18n/src/locales/ar.ts",
"packages/i18n/src/locales/de.ts",
"packages/i18n/src/locales/es.ts",
"packages/i18n/src/locales/fr.ts",
"packages/i18n/src/locales/ja.ts",
"packages/i18n/src/locales/ko.ts",
"packages/i18n/src/locales/pt.ts",
"packages/i18n/src/locales/ru.ts",
".changeset/attachments-api-disabled-no-retry.md"
],
"classifier_home": {
"chosen": "@object-ui/react (packages/react/src/utils/error-message.ts, exported as classifyLoadError + type LoadErrorKind)",
"rejected": ["@object-ui/data-objectstack (would add a brand-new dependency edge from plugin-list, which is not currently a consumer)", "a new named export left inside plugin-list (defeats the point of sharing)"],
"reasoning": "classifyLoadError is a pure classifier with zero UI/i18n text baked in (it returns only a LoadErrorKind string; each caller owns its own copy per kind), so no kernel-vs-copy split was needed -- the whole function moved as-is. @object-ui/react was picked because both @object-ui/plugin-list and @object-ui/app-shell already depend on it (zero new dependency edges either direction -- plugin-list already imports SchemaRenderer/useNavigationOverlay from it, RecordAttachmentsPanel already imports isPermissionError from it), it is sibling to isPermissionError (the same shape of function -- classify an error into a UI-relevant verdict), and @object-ui/react already depends on @object-ui/data-objectstack (used by AppShellContext.tsx) so classifyLoadError could delegate its api-disabled check to the already-exported isApiAccessDeniedError there with no cycle risk, retiring ListView's own independently-hand-rolled API_ACCESS_DENIED_CODES Set."
},
"gates": {
"head_sha": "7e1701bbd",
"results": [
{ "cmd": "pnpm exec turbo run build --filter='@object-ui/app-shell...' --filter='@object-ui/plugin-list...' --concurrency=2", "result": "29/29 tasks successful" },
{ "cmd": "pnpm exec turbo run type-check --filter='@object-ui/app-shell' --filter='@object-ui/plugin-list' --filter='@object-ui/react' --filter='@object-ui/i18n' --filter='@object-ui/data-objectstack' --concurrency=2", "result": "34/34 successful" },
{ "cmd": "pnpm exec turbo run lint --filter='@object-ui/app-shell' --filter='@object-ui/plugin-list' --filter='@object-ui/react' --filter='@object-ui/i18n' --concurrency=2", "result": "5/5 successful, 0 errors, 2422 pre-existing warnings (none newly introduced by this diff)" },
{ "cmd": "pnpm exec vitest run packages/react/ --maxWorkers=2", "result": "593 passed (44 files)" },
{ "cmd": "pnpm exec vitest run packages/plugin-list/ --maxWorkers=2", "result": "549 passed (40 files)" },
{ "cmd": "pnpm exec vitest run packages/app-shell/src/views/__tests__/ --maxWorkers=2", "result": "106 passed (11 files)" },
{ "cmd": "pnpm exec vitest run packages/i18n/ --maxWorkers=2", "result": "804 passed (45 files)" },
{ "cmd": "node scripts/check-i18n-call-site-keys.mjs", "result": "green -- 2461 pack-backed call sites, 2407/2407 literal keys resolve" },
{ "cmd": "node scripts/check-i18n-en-drift.mjs", "result": "green -- 0 en values changed, 1 key added (all-locales-key-parity's territory)" },
{ "cmd": "node scripts/check-control-bytes.mjs", "result": "OK, 4230 files scanned" },
{ "cmd": "node scripts/check-phantom-dependencies.mjs", "result": "green -- every import declared by the package that publishes it" },
{ "cmd": "node scripts/check-changeset-presence.mjs", "result": "green -- 1 changeset declared for the 4 touched released packages" },
{ "cmd": "node scripts/check-changeset-no-major.mjs", "result": "green" },
{ "cmd": "node scripts/check-changeset-fixed.mjs", "result": "green" },
{ "cmd": "node scripts/check-lint-coverage.mjs", "result": "green -- 46/46 packages" },
{ "cmd": "node scripts/check-type-check-coverage.mjs", "result": "green -- 45/46 via type-check, 1 not-compiled (pre-existing, unrelated)" }
]
},
"changesets": [".changeset/attachments-api-disabled-no-retry.md"],
"locales": {
"key": "detail.attachmentsApiUnavailable",
"value_en": "The attachments list is not available on this object.",
"packs_updated": ["en", "zh", "ar", "de", "es", "fr", "ja", "ko", "pt", "ru"]
},
"summary": "RecordAttachmentsPanel gains a fifth status, api-unavailable, for OBJECT_API_DISABLED (404) / OBJECT_API_METHOD_NOT_ALLOWED (405) sys_attachment reads -- no Retry button, honest copy instead of the retryable 'unavailable' state's sentence. Lifted ListView.classifyLoadError out of plugin-list's module scope into @object-ui/react (classifyLoadError, LoadErrorKind), consumed by both ListView (behavior unchanged) and RecordAttachmentsPanel (one new fork ahead of the existing denied/unavailable split, both left semantically untouched). The classifier now delegates its api-disabled check to the already-exported isApiAccessDeniedError (@object-ui/data-objectstack), retiring ListView's own duplicate code-list. New i18n key synced to all ten locale packs; one changeset covering all four touched publishable packages.",
"tests": "pnpm exec vitest run packages/react/ (593 passed), packages/plugin-list/ (549 passed), packages/app-shell/src/views/__tests__/ (106 passed), packages/i18n/ (804 passed) -- all at 7e1701bbd. New direct unit pin for classifyLoadError in packages/react/src/utils/error-message.test.ts. New truth-table coverage in RecordAttachmentsPanel.test.tsx: 403 PERMISSION_DENIED -> denied (unchanged pin); 500 / TypeError('Failed to fetch') -> unavailable WITH Retry (positive control); 404 OBJECT_API_DISABLED -> api-unavailable, no Retry; 405 OBJECT_API_METHOD_NOT_ALLOWED -> api-unavailable, no Retry -- plus code-alone classification, no-leak assertions, and Upload-affordance withdrawal. All network-shaped tests use a recorded dataSource.find() rejection (no global fetch mock, no swallowed console error), matching the file's existing convention.",
"open_questions": [],
"out_of_scope_findings": []
}
Activity
yinlianghui commentedon Aug 15, 2026
First-touch grading (seat
repo:objectui; sessionsession_01RnQd8iMMUwXQEV1crFmQiQ, 2026-08-15):pm:queue, size S–M, age-ordered / low priority (bounded reachability as the card itself argues:enable.filesgating + system object). The dispatch, when it rides, should take the card's own "better half": liftListView.classifyLoadErrorout of its module-local scope into a shared home so both surfaces consume ONE classifier (theisApiAccessDeniedErrorpredicate is already exported), and give the panel the no-retry copy for the api-disabled kind — rather than growing a panel-local fifth state by hand. The deliberate-judgment record is exactly what findings are for; good filing.Generated by Claude Code
yinlianghui commentedon Aug 15, 2026
Claiming — PM dispatch. Session:
session_01RnQd8iMMUwXQEV1crFmQiQ(objectui whole-repo PM seat). Branch:claude/issue-4693-api-disabled-attachments.Scope: the full card — stop offering Retry in
RecordAttachmentsPanelforOBJECT_API_DISABLED/OBJECT_API_METHOD_NOT_ALLOWED(metadata-determined, retry-invariant), preferring the lift-the-classifier half of the card's suggested shape (ListView.classifyLoadError→ a shared home both surfaces consume), withisApiAccessDeniedErrorfrom@object-ui/data-objectstackas the predicate. PR will beFixes #4693.Generated by Claude Code
yinlianghui commentedon Aug 15, 2026
{ "issue": 4693, "status": "done", "branch": "claude/issue-4693-api-disabled-attachments", "pr": "https://github.com/objectstack-ai/objectui/pull/4707", "pr_number": 4707, "pr_url": "https://github.com/objectstack-ai/objectui/pull/4707", "head_sha": "7e1701bbdb2bab4063eedf801bde21a743098c03", "premise_still_valid": true, "premise_checks": [ { "assumption": "classifyLoadError was a module-local, unexported function in packages/plugin-list/src/ListView.tsx with an independent api-disabled kind", "held": true }, { "assumption": "isApiAccessDeniedError is exported from @object-ui/data-objectstack and covers OBJECT_API_DISABLED + OBJECT_API_METHOD_NOT_ALLOWED", "held": true, "note": "also found API_ACCESS_DENIED_CODES already exported there, independently duplicating ListView's own local Set of the same two codes" }, { "assumption": "post-#4684/#4691 RecordAttachmentsPanel has a four-state loading/loaded/denied/unavailable model, denied via isPermissionError", "held": true } ], "changed_files": [ "packages/react/src/utils/error-message.ts", "packages/react/src/utils/error-message.test.ts", "packages/react/src/index.ts", "packages/plugin-list/src/ListView.tsx", "packages/app-shell/src/views/RecordAttachmentsPanel.tsx", "packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx", "packages/i18n/src/locales/en.ts", "packages/i18n/src/locales/zh.ts", "packages/i18n/src/locales/ar.ts", "packages/i18n/src/locales/de.ts", "packages/i18n/src/locales/es.ts", "packages/i18n/src/locales/fr.ts", "packages/i18n/src/locales/ja.ts", "packages/i18n/src/locales/ko.ts", "packages/i18n/src/locales/pt.ts", "packages/i18n/src/locales/ru.ts", ".changeset/attachments-api-disabled-no-retry.md" ], "classifier_home": { "chosen": "@object-ui/react (packages/react/src/utils/error-message.ts, exported as classifyLoadError + type LoadErrorKind)", "rejected": ["@object-ui/data-objectstack (would add a brand-new dependency edge from plugin-list, which is not currently a consumer)", "a new named export left inside plugin-list (defeats the point of sharing)"], "reasoning": "classifyLoadError is a pure classifier with zero UI/i18n text baked in (it returns only a LoadErrorKind string; each caller owns its own copy per kind), so no kernel-vs-copy split was needed -- the whole function moved as-is. @object-ui/react was picked because both @object-ui/plugin-list and @object-ui/app-shell already depend on it (zero new dependency edges either direction -- plugin-list already imports SchemaRenderer/useNavigationOverlay from it, RecordAttachmentsPanel already imports isPermissionError from it), it is sibling to isPermissionError (the same shape of function -- classify an error into a UI-relevant verdict), and @object-ui/react already depends on @object-ui/data-objectstack (used by AppShellContext.tsx) so classifyLoadError could delegate its api-disabled check to the already-exported isApiAccessDeniedError there with no cycle risk, retiring ListView's own independently-hand-rolled API_ACCESS_DENIED_CODES Set." }, "gates": { "head_sha": "7e1701bbd", "results": [ { "cmd": "pnpm exec turbo run build --filter='@object-ui/app-shell...' --filter='@object-ui/plugin-list...' --concurrency=2", "result": "29/29 tasks successful" }, { "cmd": "pnpm exec turbo run type-check --filter='@object-ui/app-shell' --filter='@object-ui/plugin-list' --filter='@object-ui/react' --filter='@object-ui/i18n' --filter='@object-ui/data-objectstack' --concurrency=2", "result": "34/34 successful" }, { "cmd": "pnpm exec turbo run lint --filter='@object-ui/app-shell' --filter='@object-ui/plugin-list' --filter='@object-ui/react' --filter='@object-ui/i18n' --concurrency=2", "result": "5/5 successful, 0 errors, 2422 pre-existing warnings (none newly introduced by this diff)" }, { "cmd": "pnpm exec vitest run packages/react/ --maxWorkers=2", "result": "593 passed (44 files)" }, { "cmd": "pnpm exec vitest run packages/plugin-list/ --maxWorkers=2", "result": "549 passed (40 files)" }, { "cmd": "pnpm exec vitest run packages/app-shell/src/views/__tests__/ --maxWorkers=2", "result": "106 passed (11 files)" }, { "cmd": "pnpm exec vitest run packages/i18n/ --maxWorkers=2", "result": "804 passed (45 files)" }, { "cmd": "node scripts/check-i18n-call-site-keys.mjs", "result": "green -- 2461 pack-backed call sites, 2407/2407 literal keys resolve" }, { "cmd": "node scripts/check-i18n-en-drift.mjs", "result": "green -- 0 en values changed, 1 key added (all-locales-key-parity's territory)" }, { "cmd": "node scripts/check-control-bytes.mjs", "result": "OK, 4230 files scanned" }, { "cmd": "node scripts/check-phantom-dependencies.mjs", "result": "green -- every import declared by the package that publishes it" }, { "cmd": "node scripts/check-changeset-presence.mjs", "result": "green -- 1 changeset declared for the 4 touched released packages" }, { "cmd": "node scripts/check-changeset-no-major.mjs", "result": "green" }, { "cmd": "node scripts/check-changeset-fixed.mjs", "result": "green" }, { "cmd": "node scripts/check-lint-coverage.mjs", "result": "green -- 46/46 packages" }, { "cmd": "node scripts/check-type-check-coverage.mjs", "result": "green -- 45/46 via type-check, 1 not-compiled (pre-existing, unrelated)" } ] }, "changesets": [".changeset/attachments-api-disabled-no-retry.md"], "locales": { "key": "detail.attachmentsApiUnavailable", "value_en": "The attachments list is not available on this object.", "packs_updated": ["en", "zh", "ar", "de", "es", "fr", "ja", "ko", "pt", "ru"] }, "summary": "RecordAttachmentsPanel gains a fifth status, api-unavailable, for OBJECT_API_DISABLED (404) / OBJECT_API_METHOD_NOT_ALLOWED (405) sys_attachment reads -- no Retry button, honest copy instead of the retryable 'unavailable' state's sentence. Lifted ListView.classifyLoadError out of plugin-list's module scope into @object-ui/react (classifyLoadError, LoadErrorKind), consumed by both ListView (behavior unchanged) and RecordAttachmentsPanel (one new fork ahead of the existing denied/unavailable split, both left semantically untouched). The classifier now delegates its api-disabled check to the already-exported isApiAccessDeniedError (@object-ui/data-objectstack), retiring ListView's own duplicate code-list. New i18n key synced to all ten locale packs; one changeset covering all four touched publishable packages.", "tests": "pnpm exec vitest run packages/react/ (593 passed), packages/plugin-list/ (549 passed), packages/app-shell/src/views/__tests__/ (106 passed), packages/i18n/ (804 passed) -- all at 7e1701bbd. New direct unit pin for classifyLoadError in packages/react/src/utils/error-message.test.ts. New truth-table coverage in RecordAttachmentsPanel.test.tsx: 403 PERMISSION_DENIED -> denied (unchanged pin); 500 / TypeError('Failed to fetch') -> unavailable WITH Retry (positive control); 404 OBJECT_API_DISABLED -> api-unavailable, no Retry; 405 OBJECT_API_METHOD_NOT_ALLOWED -> api-unavailable, no Retry -- plus code-alone classification, no-leak assertions, and Upload-affordance withdrawal. All network-shaped tests use a recorded dataSource.find() rejection (no global fetch mock, no swallowed console error), matching the file's existing convention.", "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code