From e0ff19632b003d66a8f0931116a26a5fd17e07c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:46:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(console):=20group=20tenancy=20posture=20af?= =?UTF-8?q?fordances=20=E2=80=94=20org=20switcher=20as=20write=20context?= =?UTF-8?q?=20+=20org=20attribution=20(ADR-0105=20Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the group posture the server widens reads to the member's whole organization set while writes land in the ACTIVE organization, so the console must stop presenting the active org as a read boundary: - @object-ui/auth: features.tenancyPosture ('single'|'group'|'isolated', exported as TenancyPosture) mirrors the server's public auth config key. - useTenancyPosture() (app-shell): posture from the cached config fetch; undefined/unrecognized keeps every group affordance off, so non-group deployments render pixel-identical to today. - WorkspaceSwitcher: under group, labels the active org 'Working organization' and explains the read/write split. - RecordFormPage (create): org-walled objects show a 'Creates in ' badge naming the D5 write target. - Default list columns (ObjectView / InterfaceListPage / ObjectDataPage): under group, org-walled objects get a TRAILING organization_id attribution column — render-time only, never persisted into saved view/page metadata. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL --- .changeset/adr-0105-group-posture-console.md | 35 ++++++ packages/app-shell/src/hooks/index.ts | 1 + .../app-shell/src/hooks/useTenancyPosture.ts | 48 +++++++++ .../src/layout/WorkspaceSwitcher.tsx | 22 +++- .../__tests__/WorkspaceSwitcher.test.tsx | 102 ++++++++++++++++++ .../views/InterfaceListPage.defaults.test.tsx | 23 ++++ .../app-shell/src/views/InterfaceListPage.tsx | 34 ++++-- .../app-shell/src/views/ObjectDataPage.tsx | 8 +- .../views/ObjectView.defaultColumns.test.tsx | 43 ++++++++ packages/app-shell/src/views/ObjectView.tsx | 38 +++++-- .../app-shell/src/views/RecordFormPage.tsx | 23 +++- packages/auth/src/index.ts | 1 + packages/auth/src/types.ts | 22 ++++ 13 files changed, 379 insertions(+), 21 deletions(-) create mode 100644 .changeset/adr-0105-group-posture-console.md create mode 100644 packages/app-shell/src/hooks/useTenancyPosture.ts create mode 100644 packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.test.tsx diff --git a/.changeset/adr-0105-group-posture-console.md b/.changeset/adr-0105-group-posture-console.md new file mode 100644 index 000000000..77161738f --- /dev/null +++ b/.changeset/adr-0105-group-posture-console.md @@ -0,0 +1,35 @@ +--- +"@object-ui/auth": minor +"@object-ui/app-shell": minor +--- + +feat(console): group tenancy posture affordances — org switcher as write +context + org attribution in read views (framework ADR-0105 Phase 1) + +Under the new `group` tenancy posture the server widens reads to every +organization the member belongs to (`organization_id IN accessible_org_ids`) +while writes land in the ACTIVE organization — so the console's existing +"which org am I in = which org's data I see" presentation becomes wrong the +moment a deployment switches postures. The ADR requires these affordances to +land WITH Phase 1, not after. + +- `@object-ui/auth`: `AuthPublicConfig.features.tenancyPosture` + (`'single' | 'group' | 'isolated'`, exported as `TenancyPosture`) mirrors + the server's public auth config key. It gates nothing — `multiOrgEnabled` + stays the capability flag; this only tells the console how to render org + context. +- `useTenancyPosture()` (app-shell): reads the posture from the cached auth + config fetch; `undefined` (older server, unrecognized value, fetch failure) + keeps every group affordance off, so non-group deployments render + pixel-identical to today. +- `WorkspaceSwitcher`: under `group` the dropdown labels the active org + "Working organization" and explains the split — new records are created + here, views show data from all your organizations. +- `RecordFormPage` (create mode): org-walled objects show a "Creates in + " badge naming the engine's write target (ADR-0105 D5 stamps + `organization_id` from the active org). +- Default list columns (`ObjectView`, `InterfaceListPage`, `ObjectDataPage`): + under `group`, org-walled objects get a TRAILING `organization_id` + attribution column so cross-org rows are attributable at a glance. + Render-time only — never persisted into saved view/page metadata, and + business fields still lead. diff --git a/packages/app-shell/src/hooks/index.ts b/packages/app-shell/src/hooks/index.ts index b2aa038a5..3bd5664f0 100644 --- a/packages/app-shell/src/hooks/index.ts +++ b/packages/app-shell/src/hooks/index.ts @@ -31,6 +31,7 @@ export { type UseUrlOverlayOptions, type UrlOverlayControls, } from './useUrlOverlay'; +export { useTenancyPosture } from './useTenancyPosture'; export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent'; export { sanitizeChatMessagesForCache, diff --git a/packages/app-shell/src/hooks/useTenancyPosture.ts b/packages/app-shell/src/hooks/useTenancyPosture.ts new file mode 100644 index 000000000..1db797798 --- /dev/null +++ b/packages/app-shell/src/hooks/useTenancyPosture.ts @@ -0,0 +1,48 @@ +/** + * useTenancyPosture — the deployment's tenancy posture (framework ADR-0105 D1), + * read from the public auth config's `features.tenancyPosture`. + * + * Under `group` posture the active organization is only the WRITE target — + * reads span every organization the member belongs to (the server enforces + * `organization_id IN accessible_org_ids`; the client never filters for + * security). Components use this hook to key the group-only affordances: + * write-context labeling on the org switcher, the create-form target-org + * badge, and org attribution columns in default list views. + * + * Returns `undefined` until the config resolves, and stays `undefined` when + * the server doesn't report a posture (older server) or reports an + * unrecognized value — so every group affordance fails toward today's + * rendering. The auth client caches the config fetch behind one promise, so + * concurrent mounts share a single request. + */ + +import { useEffect, useState } from 'react'; +import { useAuth } from '@object-ui/auth'; +import type { TenancyPosture } from '@object-ui/auth'; + +const POSTURES: readonly TenancyPosture[] = ['single', 'group', 'isolated']; + +export function useTenancyPosture(): TenancyPosture | undefined { + const { getAuthConfig } = useAuth(); + const [posture, setPosture] = useState(undefined); + + useEffect(() => { + let cancelled = false; + getAuthConfig?.() + .then((cfg) => { + if (cancelled) return; + const reported = cfg?.features?.tenancyPosture; + if (reported && (POSTURES as readonly string[]).includes(reported)) { + setPosture(reported); + } + }) + .catch(() => { + /* config unavailable — leave undefined, group affordances stay off */ + }); + return () => { + cancelled = true; + }; + }, [getAuthConfig]); + + return posture; +} diff --git a/packages/app-shell/src/layout/WorkspaceSwitcher.tsx b/packages/app-shell/src/layout/WorkspaceSwitcher.tsx index 90f75399b..c63dea332 100644 --- a/packages/app-shell/src/layout/WorkspaceSwitcher.tsx +++ b/packages/app-shell/src/layout/WorkspaceSwitcher.tsx @@ -12,6 +12,11 @@ * (full-page reload so the active-org context refreshes app-wide, mirroring * OrganizationsPage), plus shortcuts to manage members / create a workspace. * - No org context at all: renders nothing. + * + * Under `group` tenancy posture (ADR-0105) the switcher's meaning changes: + * the active organization is only the WRITE target — reads span every + * organization the member belongs to — so the dropdown labels it as the + * working organization instead of implying it bounds what the user sees. */ import { useEffect, useState } from 'react'; @@ -29,6 +34,7 @@ import { } from '@object-ui/components'; import { ChevronsUpDown, Check, Plus, Users } from 'lucide-react'; import { resolveRootUrl } from '../console/organizations/resolveHomeUrl'; +import { useTenancyPosture } from '../hooks/useTenancyPosture'; function getOrgInitials(name: string): string { return name @@ -52,6 +58,7 @@ export function WorkspaceSwitcher() { const navigate = useNavigate(); const { organizations, activeOrganization, switchOrganization, getAuthConfig } = useAuth(); const [multiOrgDisabled, setMultiOrgDisabled] = useState(false); + const isGroupPosture = useTenancyPosture() === 'group'; useEffect(() => { let cancelled = false; @@ -105,8 +112,21 @@ export function WorkspaceSwitcher() { - {t('organization.switcher.label', { defaultValue: 'Switch organization' })} + {isGroupPosture + ? t('organization.switcher.groupLabel', { defaultValue: 'Working organization' }) + : t('organization.switcher.label', { defaultValue: 'Switch organization' })} + {isGroupPosture && ( +

+ {t('organization.switcher.groupHint', { + defaultValue: + 'New records are created here. Views show data from all your organizations.', + })} +

+ )} {orgList.map((org) => ( ({ + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), +})); + +const navigate = vi.fn(); +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigate, +})); + +let authState: Record; +vi.mock('@object-ui/auth', () => ({ useAuth: () => authState })); + +vi.mock('../../console/organizations/resolveHomeUrl', () => ({ resolveRootUrl: () => '/root' })); + +// Passthrough dropdown primitives so label/hint render without interaction. +vi.mock('@object-ui/components', () => ({ + DropdownMenu: (p: any) =>
{p.children}
, + DropdownMenuTrigger: (p: any) =>