feat: distinguish same-name GPU service resources across users

Resource names are unique per owning principal, so different users
can legally create same-name GPU instances, templates, SSH public
keys, storage volumes and storage types — but the admin's
cross-tenant lists and the instance-create template picker rendered
them indistinguishably.

- Group the template picker by owning scope: Your Templates, Global
  Templates, then — admin cross-tenant view only — one group per
  owner. A single group renders flat without headers. A plugin can
  take over grouping via hooks.useTemplateOwnerGroups when its
  principal model scopes templates beyond USER owners; a plugin
  without the hook keeps the flat list.
- Add an admin-only Creator column to the Instances, SSH Public
  Keys, Storage and Storage Types tables, sitting right before
  Created (mirroring the API Keys page convention). Resolves
  creator_id to a username via the user directory, falling back to
  owner_principal_id for legacy rows — a personal-scope row's
  creator IS its owner.
- Tag template cards with their owner scope (Global / username) for
  admins.

The Creator column and the card tag defer to a plugin that registers
the page's list columns / the OwnerScopeTag slot. Requires the
gpustack server change that records creator_id on templates, SSH
public keys and storage types.
This commit is contained in:
gitlawr
2026-06-12 20:22:56 +08:00
committed by Lawrence Li
parent 778ea36a9d
commit 7a76bda484
20 changed files with 395 additions and 54 deletions
+5 -1
View File
@@ -174,5 +174,9 @@ export default {
'Please enter the temporary storage capacity',
'gpuservice.form.rule.name':
"Lowercase letters, numbers, and '-'. Start and end with a letter or number, no consecutive '-', max 63 characters.",
'gpuservice.form.storage.select': 'Select Storage'
'gpuservice.form.storage.select': 'Select Storage',
'gpuservice.creator': 'Creator',
'gpuservice.owner.global': 'Global',
'gpuservice.template.group.yours': 'Your Templates',
'gpuservice.template.group.global': 'Global Templates'
};
+5 -1
View File
@@ -174,5 +174,9 @@ export default {
'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips':
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
'gpuservice.form.storage.select': 'ストレージを選択'
'gpuservice.form.storage.select': 'ストレージを選択',
'gpuservice.creator': '作成者',
'gpuservice.owner.global': 'グローバル',
'gpuservice.template.group.yours': '自分のテンプレート',
'gpuservice.template.group.global': 'グローバルテンプレート'
};
+5 -1
View File
@@ -171,5 +171,9 @@ export default {
'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips':
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
'gpuservice.form.storage.select': 'Выберите хранилище'
'gpuservice.form.storage.select': 'Выберите хранилище',
'gpuservice.creator': 'Создатель',
'gpuservice.owner.global': 'Глобальный',
'gpuservice.template.group.yours': 'Ваши шаблоны',
'gpuservice.template.group.global': 'Глобальные шаблоны'
};
+5 -1
View File
@@ -169,5 +169,9 @@ export default {
'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips':
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
'gpuservice.form.storage.select': 'Depolamayı Seç'
'gpuservice.form.storage.select': 'Depolamayı Seç',
'gpuservice.creator': 'Oluşturan',
'gpuservice.owner.global': 'Genel',
'gpuservice.template.group.yours': 'Şablonlarınız',
'gpuservice.template.group.global': 'Genel Şablonlar'
};
+5 -1
View File
@@ -158,5 +158,9 @@ export default {
'gpuservice.storage.tempCapacity.required': '请输入临时存储容量',
'gpuservice.form.rule.name':
'由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。',
'gpuservice.form.storage.select': '选择存储'
'gpuservice.form.storage.select': '选择存储',
'gpuservice.creator': '创建者',
'gpuservice.owner.global': '全局',
'gpuservice.template.group.yours': '我的模板',
'gpuservice.template.group.global': '全局模板'
};
@@ -0,0 +1,45 @@
import { getGPUStackPlugin } from '@/plugins';
import { ThemeTag } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max';
import useUserDirectory from '../hooks/use-user-directory';
/**
* Owner tag for template cards, disambiguating same-name templates in
* the admin's cross-tenant view. Renders nothing for non-admin callers
* (the management page is `mine`-scoped for them) and when a plugin
* provides its own `OwnerScopeTag` slot.
*/
const OwnerTag: React.FC<{ ownerId?: number | null }> = ({ ownerId }) => {
const intl = useIntl();
const access = useAccess();
const pluginOwnsTag = !!getGPUStackPlugin()?.components?.OwnerScopeTag;
const show = !!access.canSeeAdmin && !pluginOwnsTag;
const users = useUserDirectory(show);
if (!show) {
return null;
}
if (ownerId == null) {
return (
<ThemeTag color="gold" style={{ fontWeight: 400 }}>
{intl.formatMessage({ id: 'gpuservice.owner.global' })}
</ThemeTag>
);
}
const username = users.get(ownerId);
if (!username) {
// Directory still loading, or a non-USER principal (built-in
// platform principal) — nothing meaningful to show on a card.
return null;
}
return (
<ThemeTag color="blue" style={{ fontWeight: 400 }}>
{username}
</ThemeTag>
);
};
export default OwnerTag;
@@ -0,0 +1,81 @@
import { getGPUStackPlugin } from '@/plugins';
import { AutoTooltip } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max';
import { useMemo } from 'react';
import useUserDirectory from './use-user-directory';
type CreatorAware = {
creator_id?: number | null;
owner_principal_id?: number | null;
};
// Concrete column shape rather than antd's `ColumnsType` union:
// spreading a union-typed array into a page's column literal breaks
// contextual typing for the sibling inline columns (their render
// params degrade to implicit `any`), while a concrete object type
// composes cleanly — same contract the plugin columns use.
type CreatorColumn<T> = {
title: string;
key: string;
ellipsis: { showTitle: false };
render: (text: any, record: T) => React.ReactNode;
};
export const CreatorLabel: React.FC<{
record: CreatorAware;
users: Map<number, string>;
}> = ({ record, users }) => {
// Legacy rows predate `creator_id`. A personal-scope row's creator
// IS its owner, so the owner id doubles as the fallback — the same
// derivation the backend migration backfills with; keeping it here
// covers servers that haven't run the migration yet.
const id = record.creator_id ?? record.owner_principal_id;
const username = id != null ? users.get(id) : undefined;
if (username) {
return (
<AutoTooltip ghost maxWidth={240}>
{username}
</AutoTooltip>
);
}
// Unresolvable: the directory is still loading, or a legacy row
// whose only attribution is a non-USER principal (platform-owned).
return <span>-</span>;
};
/**
* "Creator" column for the GPU-service list pages, attributing each
* row to the user who created it — disambiguates same-name rows from
* different users in the admin's cross-tenant view, and reads as a
* plain username for admin-created rows too. Returns `[]` for
* non-admin callers (their lists only ever contain their own rows)
* and when a plugin registers list columns for `pageKey` — the
* plugin's own attribution column covers the same ground.
*/
const useCreatorColumn = <T extends CreatorAware>(
pageKey: string
): CreatorColumn<T>[] => {
const intl = useIntl();
const access = useAccess();
const pluginOwnsSlot = !!(
getGPUStackPlugin()?.listExtraColumns as Record<string, unknown> | undefined
)?.[pageKey];
const show = !!access.canSeeAdmin && !pluginOwnsSlot;
const users = useUserDirectory(show);
return useMemo(() => {
if (!show) return [];
return [
{
title: intl.formatMessage({ id: 'gpuservice.creator' }),
key: 'creator',
ellipsis: { showTitle: false as const },
render: (_text: any, record: T) => (
<CreatorLabel record={record} users={users} />
)
}
];
}, [show, users, intl]);
};
export default useCreatorColumn;
@@ -0,0 +1,52 @@
import { queryUserDirectory } from '@/pages/users/apis';
import { useEffect, useState } from 'react';
// Module-level cache: the directory is fetched once per app session and
// shared by every consumer (owner columns on the Instances / Public Keys /
// Storage tables, the template owner tags, the create-instance template
// picker). A failed fetch clears the cache so the next consumer retries.
let directoryPromise: Promise<Map<number, string>> | null = null;
const fetchDirectory = (): Promise<Map<number, string>> => {
directoryPromise ??= queryUserDirectory({ page: -1 })
.then(
(res) =>
new Map(
(res.items || [])
.filter((u) => u.id != null)
.map((u) => [u.id as number, u.username])
)
)
.catch((error) => {
directoryPromise = null;
throw error;
});
return directoryPromise;
};
/**
* id → username map of all users, for resolving `owner_principal_id`
* to a display name (a USER principal's id IS the user's id).
*
* Only fetches when `enabled` — callers gate on platform admin, since
* `/user-directory` 403s for regular users (who only ever see their
* own resources anyway). Returns an empty map until loaded.
*/
export default function useUserDirectory(enabled: boolean) {
const [users, setUsers] = useState<Map<number, string>>(new Map());
useEffect(() => {
if (!enabled) return undefined;
let alive = true;
fetchDirectory()
.then((map) => {
if (alive) setUsers(map);
})
.catch(() => {});
return () => {
alive = false;
};
}, [enabled]);
return users;
}
@@ -2,7 +2,9 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useSubmitLock from '@/hooks/use-submit-lock';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import useUserDirectory from '@/pages/gpu-service/hooks/use-user-directory';
import Separator from '@/pages/llmodels/components/separator';
import { getGPUStackPlugin } from '@/plugins';
import { SearchOutlined } from '@ant-design/icons';
import {
AlertBlockInfo,
@@ -10,7 +12,7 @@ import {
GSDrawer,
ModalFooter
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useIntl, useModel } from '@umijs/max';
import { Empty, Input, Typography } from 'antd';
import _ from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -18,7 +20,7 @@ import { ListItem as TemplateItem } from '../../templates/config/types';
import useQueryTemplates from '../../templates/services/use-query-templates';
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
import GPUServiceInstanceForm from '../forms';
import TemplateSelector from '../forms/template-selector';
import TemplateSelector, { TemplateGroup } from '../forms/template-selector';
import useQueryInstanceTypes from '../services/use-query-instance-types';
import styles from '../styles/instances.module.less';
import InstanceTypeList from './instance-type-list';
@@ -79,6 +81,12 @@ const AddModal: React.FC<AddModalProps> = ({
realAction
}) => {
const intl = useIntl();
const { initialState } = useModel('@@initialState') || {};
const currentUser = initialState?.currentUser;
const pluginActive = !!getGPUStackPlugin();
const userDirectory = useUserDirectory(
!!currentUser?.is_admin && !pluginActive
);
const form = useRef<any>(null);
const sessionRef = useRef(0);
const [instanceTypeSelection, setInstanceTypeSelection] = useState<{
@@ -395,6 +403,89 @@ const AddModal: React.FC<AddModalProps> = ({
);
});
// Group the picker by owning scope so same-name templates stay
// distinguishable: the caller's own templates first, then the
// admin-curated Global presets, then — platform admin's cross-tenant
// view only — other users' templates, one group per owner.
//
// The default buckets below assume every non-Global owner is a USER
// principal. A plugin's principal model may scope templates to other
// owner kinds (no user-directory entry, not the caller's user id),
// which these buckets would mislabel — so a plugin can take over
// grouping via `hooks.useTemplateOwnerGroups`. The registry is wired
// at boot, so the conditional hook call is render-stable — same
// contract as `usePluginListColumns`' function entries.
const usePluginTemplateGroups = getGPUStackPlugin()?.hooks
?.useTemplateOwnerGroups as
| ((items: TemplateItem[]) => TemplateGroup[])
| undefined;
const pluginTemplateGroups = usePluginTemplateGroups?.(filteredTemplates);
const templateGroups: TemplateGroup[] = useMemo(() => {
if (pluginTemplateGroups) {
return pluginTemplateGroups;
}
if (pluginActive) {
// Plugin present but without the grouping hook (older plugin
// build): keep the flat list rather than mislabeling owners
// outside the USER-principal model.
return filteredTemplates.length
? [{ key: 'all', label: null, items: filteredTemplates }]
: [];
}
const yours: TemplateItem[] = [];
const globals: TemplateItem[] = [];
const byOwner = new Map<number, TemplateItem[]>();
filteredTemplates.forEach((item) => {
if (item.owner_principal_id == null) {
globals.push(item);
} else if (item.owner_principal_id === currentUser?.id) {
yours.push(item);
} else {
const list = byOwner.get(item.owner_principal_id) || [];
list.push(item);
byOwner.set(item.owner_principal_id, list);
}
});
const groups: TemplateGroup[] = [];
if (yours.length) {
groups.push({
key: 'yours',
label: intl.formatMessage({ id: 'gpuservice.template.group.yours' }),
items: yours
});
}
if (globals.length) {
groups.push({
key: 'global',
label: intl.formatMessage({ id: 'gpuservice.template.group.global' }),
items: globals
});
}
groups.push(
...[...byOwner.entries()]
.map(([ownerId, items]) => ({
key: `owner-${ownerId}`,
// `#id` is a placeholder for the moment before the user
// directory resolves (the memo recomputes once it lands)
// and for the API-only case of a non-USER owner.
label: userDirectory.get(ownerId) || `#${ownerId}`,
items
}))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
);
return groups;
}, [
filteredTemplates,
currentUser?.id,
userDirectory,
intl,
pluginActive,
pluginTemplateGroups
]);
const handleSubmit = () => {
guard(() => form.current?.submit());
};
@@ -531,7 +622,7 @@ const AddModal: React.FC<AddModalProps> = ({
{filteredTemplates.length > 0 && initializedRef.current ? (
<TemplateSelector
value={templateId}
dataList={filteredTemplates}
groups={templateGroups}
onChange={handleTemplateChange}
/>
) : (
@@ -110,6 +110,7 @@ export interface ListItem extends FormData {
created_at: string;
updated_at: string;
deleted_at?: string | null;
creator_id?: number | null;
status?: InstanceStatus | null;
}
@@ -1,5 +1,6 @@
import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Fragment } from 'react';
import styled from 'styled-components';
import { ListItem as TemplateItem } from '../../templates/config/types';
@@ -9,6 +10,13 @@ const TemplateGrid = styled.div`
gap: 16px;
`;
const GroupTitle = styled.div`
font-size: 12px;
font-weight: 500;
color: var(--ant-color-text-tertiary);
margin-bottom: -8px;
`;
const TemplateContent = styled.div`
display: flex;
flex-direction: column;
@@ -41,16 +49,22 @@ const TemplateContent = styled.div`
}
`;
export interface TemplateGroup {
key: string;
label: React.ReactNode;
items: TemplateItem[];
}
interface TemplateSelectorProps {
value?: number;
onChange?: (value: number, item: TemplateItem) => void;
dataList?: TemplateItem[];
groups?: TemplateGroup[];
}
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
value,
onChange,
dataList = []
groups = []
}) => {
const intl = useIntl();
@@ -59,48 +73,59 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
onChange?.(item.id, item);
};
const renderItem = (item: TemplateItem) => (
<TemplateCard
key={item.id}
clickable
ghost
hoverable
height={102}
active={value === item.id}
onClick={() => handleSelect(item)}
>
<TemplateContent>
<div className="name">
<AutoTooltip ghost minWidth={20}>
{item.displayName || item.name || '-'}
</AutoTooltip>
</div>
<div className="info">
<span>
<IconFont className="icon" type="icon-model" />{' '}
{intl.formatMessage({
id: 'gpuservice.instance.template.image'
})}
:
</span>
<AutoTooltip ghost minWidth={20}>
<span className="value">{item.spec?.image || '-'}</span>
</AutoTooltip>
</div>
<div className="info">
<span>
<IconFont className="icon" type="icon-storage-outlined" />{' '}
{intl.formatMessage({
id: 'gpuservice.instance.template.mount'
})}
:
</span>
<span className="value">{item.spec?.volumeMount || '-'}</span>
</div>
</TemplateContent>
</TemplateCard>
);
// A single group renders flat — the header would only restate what
// the whole list already is.
const showGroupTitles = groups.length > 1;
return (
<TemplateGrid>
{dataList.map((item: TemplateItem) => (
<TemplateCard
key={item.id}
clickable
ghost
hoverable
height={102}
active={value === item.id}
onClick={() => handleSelect(item)}
>
<TemplateContent>
<div className="name">
<AutoTooltip ghost minWidth={20}>
{item.displayName || item.name || '-'}
</AutoTooltip>
</div>
<div className="info">
<span>
<IconFont className="icon" type="icon-model" />{' '}
{intl.formatMessage({
id: 'gpuservice.instance.template.image'
})}
:
</span>
<AutoTooltip ghost minWidth={20}>
<span className="value">{item.spec?.image || '-'}</span>
</AutoTooltip>
</div>
<div className="info">
<span>
<IconFont className="icon" type="icon-storage-outlined" />{' '}
{intl.formatMessage({
id: 'gpuservice.instance.template.mount'
})}
:
</span>
<span className="value">{item.spec?.volumeMount || '-'}</span>
</div>
</TemplateContent>
</TemplateCard>
{groups.map((group) => (
<Fragment key={group.key}>
{showGroupTitles && <GroupTitle>{group.label}</GroupTitle>}
{group.items.map(renderItem)}
</Fragment>
))}
</TemplateGrid>
);
@@ -1,3 +1,4 @@
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { ExportOutlined } from '@ant-design/icons';
import {
@@ -172,6 +173,7 @@ const useInstancesColumns = ({
const intl = useIntl();
const access = useAccess();
const pluginCols = usePluginListColumns('gpuInstances');
const creatorCols = useCreatorColumn<ListItem>('gpuInstances');
const renderInstanceType = (record: ListItem) => {
const description =
@@ -403,6 +405,7 @@ const useInstancesColumns = ({
</AutoTooltip>
)
},
...creatorCols,
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
@@ -441,7 +444,8 @@ const useInstancesColumns = ({
clusterList,
intl,
pvCapacityByName,
pluginCols
pluginCols,
creatorCols
]);
};
@@ -14,6 +14,7 @@ export interface ListItem {
updated_at: string;
deleted_at?: string | null;
owner_principal_id?: number | null;
creator_id?: number | null;
name?: string;
displayName?: string | null;
description?: string | null;
@@ -1,3 +1,4 @@
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
@@ -35,6 +36,7 @@ const usePublicKeyColumns = ({
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
const pluginCols = usePluginListColumns('gpuPublicKeys');
const creatorCols = useCreatorColumn<ListItem>('gpuPublicKeys');
return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
@@ -62,6 +64,7 @@ const usePublicKeyColumns = ({
)
},
...pluginRendered,
...creatorCols,
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
@@ -89,7 +92,7 @@ const usePublicKeyColumns = ({
)
}
];
}, [handleSelect, sortOrder, intl, pluginCols]);
}, [handleSelect, sortOrder, intl, pluginCols, creatorCols]);
};
export default usePublicKeyColumns;
@@ -36,6 +36,7 @@ export interface ListItem {
updated_at: string;
deleted_at?: string | null;
owner_principal_id?: number | null;
creator_id?: number | null;
displayName?: string | null;
description?: string | null;
name: string;
@@ -1,3 +1,4 @@
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { FolderOutlined } from '@ant-design/icons';
import {
@@ -48,6 +49,7 @@ const useStorageTypeColumns = ({
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
const pluginCols = usePluginListColumns('gpuStorageTypes');
const creatorCols = useCreatorColumn<ListItem>('gpuStorageTypes');
return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
@@ -79,6 +81,7 @@ const useStorageTypeColumns = ({
sorter: false,
render: (_text, record) => getKindLabel(record)
},
...creatorCols,
// {
// title: intl.formatMessage({ id: 'common.table.description' }),
// dataIndex: 'description',
@@ -115,7 +118,7 @@ const useStorageTypeColumns = ({
)
}
];
}, [handleSelect, sortOrder, intl, pluginCols]);
}, [handleSelect, sortOrder, intl, pluginCols, creatorCols]);
};
export default useStorageTypeColumns;
@@ -23,6 +23,7 @@ export interface ListItem {
updated_at: string;
deleted_at?: string | null;
owner_principal_id?: number | null;
creator_id?: number | null;
displayName?: string | null;
description?: string | null;
name: string;
@@ -1,3 +1,4 @@
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
@@ -20,6 +21,7 @@ const useStorageColumns = ({
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
const pluginCols = usePluginListColumns('gpuStorage');
const creatorCols = useCreatorColumn<ListItem>('gpuStorage');
return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
@@ -68,6 +70,7 @@ const useStorageColumns = ({
sorter: false,
render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-')
},
...creatorCols,
// {
// title: intl.formatMessage({ id: 'common.table.status' }),
// dataIndex: ['status', 'phase'],
@@ -111,7 +114,14 @@ const useStorageColumns = ({
)
}
];
}, [handleSelect, sortOrder, storageClassList, intl, pluginCols]);
}, [
handleSelect,
sortOrder,
storageClassList,
intl,
pluginCols,
creatorCols
]);
};
export default useStorageColumns;
@@ -14,6 +14,7 @@ import tensorflowkLogo from '@/assets/logo/tensorflow.svg';
import ubuntuLogo from '@/assets/logo/ubuntu_logo.png';
import vllmLogo from '@/assets/logo/vllm.png';
import PluginExtraFields from '@/components/plugin-extra-fields';
import OwnerTag from '@/pages/gpu-service/components/owner-tag';
import {
GPUsConfigs,
manfacturerValueMap
@@ -266,6 +267,7 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
name="OwnerScopeTag"
context={{ ownerPrincipalId: data.owner_principal_id }}
/>
<OwnerTag ownerId={data.owner_principal_id} />
</CardName>
<InfoItem>
<span>
@@ -40,6 +40,7 @@ export interface ListItem extends FormData {
// the `OwnerScopeTag` slot on the card so the owner is visible at a
// glance. Optional on the wire — absent in single-owner builds.
owner_principal_id?: number | null;
creator_id?: number | null;
created_at?: string;
updated_at?: string;
}