+
+ {t('form.noPermissionToSave')}
+
+
+ );
+ }
+
// `created-record` reaches this branch only on the fallback path above (no
// id in the response, or nowhere to land) — a successful redirect navigates
// away instead. It carries no author-supplied title/message, so it renders
diff --git a/ui/apps/console/src/components/FormPage.upfrontWriteRefusal-29187.test.tsx b/ui/apps/console/src/components/FormPage.upfrontWriteRefusal-29187.test.tsx
new file mode 100644
index 00000000000..096ba049a11
--- /dev/null
+++ b/ui/apps/console/src/components/FormPage.upfrontWriteRefusal-29187.test.tsx
@@ -0,0 +1,199 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack-ai/objectstack#29187 — an internal form whose write the caller
+ * holds no grant for is refused up front, not at Submit.
+ *
+ * A member with read but no create right opened a form link, typed the whole
+ * record, and only on Submit learned they may not create (#29019 made that
+ * refusal read well; the typing was still lost). The page now asks the
+ * affordance map's form row against the console's `/auth/me/permissions`
+ * answer as soon as the form has loaded: a create form the create grant, an
+ * edit form (`?recordId=`) the update grant. Refused, it draws the submit-time
+ * refusal's own sentence and NO field, so there is nothing to type and lose.
+ *
+ * What is real: `FormPage`, the real `MePermissionsProvider` fed the served
+ * payload shape (`initialPermissions`), the affordance resolver and the i18n
+ * pack. The network is a stubbed `fetch` answering the view, object and
+ * record reads; a POST would be counted, and none is ever made here.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { MePermissionsProvider, type MePermissionsAuthenticatedResponse } from '@object-ui/permissions';
+import { createI18n } from '@object-ui/i18n';
+import { FormPage } from './FormPage';
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+const OBJECT = 'qar8_fsb';
+const REFUSAL = "You don't have permission to save this record.";
+
+const FIELDS = {
+ name: { type: 'text', label: 'Name' },
+ email: { type: 'text', label: 'Email' },
+};
+
+let posts = 0;
+
+function answer(status: number, body: unknown): Response {
+ const text = JSON.stringify(body);
+ return {
+ ok: status < 400,
+ status,
+ statusText: status < 400 ? 'OK' : 'Refused',
+ json: async () => JSON.parse(text),
+ text: async () => text,
+ } as unknown as Response;
+}
+
+beforeEach(() => {
+ posts = 0;
+ createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
+ const view = {
+ name: `${OBJECT}.form`,
+ object: OBJECT,
+ viewKind: 'form',
+ label: 'Feedback',
+ config: { type: 'simple', sections: [{ fields: ['name', 'email'] }] },
+ };
+ const publicPayload = {
+ slug: 'feedback',
+ object: OBJECT,
+ label: 'Feedback',
+ form: { type: 'simple', sections: [{ fields: ['name', 'email'] }] },
+ objectSchema: { name: OBJECT, fields: FIELDS },
+ };
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (url: string, init?: RequestInit) => {
+ const u = String(url);
+ if (init?.method && init.method !== 'GET') {
+ posts += 1;
+ return answer(403, { error: 'denied', code: 'PERMISSION_DENIED' });
+ }
+ if (u.includes('/meta/view/')) return answer(200, view);
+ if (u.includes('/meta/object/')) return answer(200, { name: OBJECT, label: 'Feedback', fields: FIELDS });
+ if (u.includes(`/data/${OBJECT}/r1`)) {
+ return answer(200, { object: OBJECT, id: 'r1', record: { id: 'r1', name: 'Ada', email: 'ada@example.com' } });
+ }
+ if (u.includes('/forms/feedback')) return answer(200, publicPayload);
+ throw new Error(`unstubbed fetch: ${u}`);
+ }),
+ );
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+/** The served `/auth/me/permissions` answer, granting `grants` on the form's object. */
+function permissions(grants: Record