Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .agents/shared/metrics/hits.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,13 @@
{"ts":"2026-08-27T19:12:05.555Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"}
{"ts":"2026-08-27T19:12:08.000Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"}
{"ts":"2026-08-27T19:12:23.402Z","tool":"Edit","file":"apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"}
{"ts":"2026-08-31T10:21:08.940Z","tool":"Edit","file":"apps/app/src/shared/utils/daoUtils/daoUtils.ts","rule":"plugin-visibility","bytes":3517,"elapsed_ms":6,"adapter":"claude"}
{"ts":"2026-08-31T11:12:12.662Z","tool":"Edit","file":"apps/app/src/modules/governance/utils/proposalUtils/proposalUtils.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":4,"adapter":"claude"}
{"ts":"2026-08-31T11:16:13.943Z","tool":"Edit","file":"apps/app/src/shared/utils/daoUtils/daoUtils.test.ts","rule":"plugin-visibility","bytes":3517,"elapsed_ms":3,"adapter":"claude"}
{"ts":"2026-08-31T11:16:13.943Z","tool":"Edit","file":"apps/app/src/shared/utils/daoUtils/daoUtils.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":3,"adapter":"claude"}
{"ts":"2026-08-31T11:16:23.786Z","tool":"Edit","file":"apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts","rule":"plugin-visibility","bytes":3517,"elapsed_ms":3,"adapter":"claude"}
{"ts":"2026-08-31T11:16:29.488Z","tool":"Edit","file":"apps/app/src/shared/utils/daoUtils/daoUtils.ts","rule":"plugin-visibility","bytes":3517,"elapsed_ms":3,"adapter":"claude"}
{"ts":"2026-08-31T11:16:37.242Z","tool":"Edit","file":".agents/shared/skills/rules/plugin-visibility.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":3,"adapter":"claude"}
{"ts":"2026-08-31T11:16:41.725Z","tool":"Edit","file":".agents/shared/skills/rules/plugin-visibility.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":2,"adapter":"claude"}
{"ts":"2026-08-31T11:16:50.965Z","tool":"Edit","file":".agents/shared/skills/rules/plugin-visibility.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":3,"adapter":"claude"}
{"ts":"2026-08-31T11:17:03.502Z","tool":"Edit","file":".agents/shared/skills/rules/plugin-visibility.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":2,"adapter":"claude"}
9 changes: 6 additions & 3 deletions .agents/shared/skills/rules/plugin-visibility.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: plugin-visibility
description: Two filters sit on the plugin list — CMS visibility (opt-in, presentation-only) and unknown interface type (opt-out, applied by default). Never filter a list you then look up by address/slug/type.
description: Two filters sit on the plugin list — CMS visibility (opt-in, presentation-only) and app support (opt-out, applied by default). Never filter a list you then look up by address/slug/type.
globs: apps/app/src/shared/hooks/useDaoPlugins/**, apps/app/src/shared/utils/daoVisibilityUtils/**, apps/app/src/shared/utils/daoUtils/**, apps/app/src/plugins/*/hooks/*NormalizeActions/**, apps/app/src/modules/governance/dialogs/selectPluginDialog/**
kind: rule
---
Expand All @@ -12,13 +12,14 @@ Two independent filters sit on the plugin list, and their defaults point in **op
| Axis | Flag | Default | Why |
| --- | --- | --- | --- |
| CMS hidden (`pluginsToHide`) | `visibleOnly: true` | off — list is complete | A hidden plugin still works; only its presentation is suppressed, so lookups must resolve it. |
| Unknown interface type | `includeUnsupported: true` | **on — unknowns are dropped** | The app has no UI to render an unclassified plugin with, so it is never a valid target. |
| Not supported by the app | `includeUnsupported: true` | **on — unsupported plugins are dropped** | The app has no UI to render an unclassified plugin with, so it is never a valid target. |

## Canon

- `src/shared/hooks/useDaoPlugins/useDaoPlugins.ts` — `visibleOnly?: boolean` gates `filterHiddenPlugins`. Default `false` = full canonical list.
- `src/shared/utils/daoVisibilityUtils/daoVisibilityUtils.ts` — the only place that removes hidden plugins.
- `src/shared/utils/daoUtils/daoUtils.ts` — `isSupportedPlugin` is the single predicate for the unknown axis, and `getDaoPlugins` applies it unless `includeUnsupported` is set. Deliberately an interface-type check, never a plugin-registry lookup: the registry populates on demand, so a registry check would mark every plugin unsupported during server rendering.
- `src/shared/utils/daoUtils/daoUtils.ts` — `isSupportedPlugin` is the single predicate for the support axis, and `getDaoPlugins` applies it unless `includeUnsupported` is set. Two backend fields feed it: an unresolved `interfaceType` (`UNKNOWN`), and `isSupported: false`, which the backend sets for a plugin installed outside the standard OSx flow (APP-1113 — such a DAO's proposals failed to load because the app accepted the plugin as a governance target). `isSupported` is optional and only `false` counts: an absent flag means supported, so older API responses keep working. Deliberately a check on those fields, never a plugin-registry lookup: the registry populates on demand, so a registry check would mark every plugin unsupported during server rendering.
- Any DAO-level "does this DAO have plugins" predicate must route through `isSupportedPlugin` — `hasPluginBody` and `hasSupportedPlugins` both do. Skipping it lets nav items and dashboard sections render for a DAO whose only plugin `getDaoPlugins` drops, so the nav link bounces the user straight back with a redirect.

## The invariant

Expand All @@ -40,6 +41,8 @@ if (plugin == null) {
}
```

Anything derived from a resolved plugin inherits that `undefined`. `proposalUtils.getProposalSlug` returns `undefined` when the proposal's plugin does not resolve, so every prop built from the slug (`proposalSlug`, `href`, `id`) must be typed optional and read with `?.` — a list item for such a proposal renders unlinked instead of throwing.

## Server Components & navigation

Server Components and `navigationDao` can't call the hook. They call `daoVisibilityUtils.filterHiddenPlugins(...)` directly on `daoUtils.getDaoPlugins(...)` / `dao.plugins` for redirect/default/nav decisions. That is the intended presentation boundary — keep it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aragon/app": patch
---

Filter out plugins flagged by the backend as unsupported.
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ export interface IDaoProposalListDefaultItemProps<
*/
proposal: TProposal;
/**
* Slug of the proposal.
* Slug of the proposal. Undefined when the proposal's plugin cannot be
* resolved on the DAO (uninstalled or unsupported plugin), in which case the
* item renders without a link.
*/
proposalSlug: string;
proposalSlug?: string;
}

export const DaoProposalListDefaultItem: React.FC<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ describe('proposalUtils', () => {
const result = proposalUtils.getDisplayTitle(proposal, 'admin-1');
expect(result).toEqual('ADMIN-1');
});

it('returns an empty title when neither the title nor the slug are set', () => {
const proposal = generateProposal({ title: '' });
expect(proposalUtils.getDisplayTitle(proposal)).toEqual('');
});

it('returns the proposal title when the slug is not set', () => {
const proposal = generateProposal({ title: 'my-proposal' });
expect(proposalUtils.getDisplayTitle(proposal)).toEqual(
'my-proposal',
);
});
});

describe('getProposalSlug', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,14 @@ class ProposalUtils {
/**
* Returns the proposal title, falling back to the proposal slug when the metadata
* cannot be resolved so the proposal always stays identifiable in the UI.
* The slug is optional because `getProposalSlug` cannot build one when the
* proposal's plugin does not resolve (uninstalled or unsupported plugin): an
* untitled proposal then renders without a title instead of crashing.
*/
getDisplayTitle = (
proposal: Pick<IProposal, 'title'>,
proposalSlug: string,
): string => proposal.title || proposalSlug.toUpperCase();
proposalSlug?: string,
): string => proposal.title || (proposalSlug?.toUpperCase() ?? '');

getProposalSlug = (
proposal: Pick<IProposal, 'incrementalId' | 'pluginAddress'>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ export class DaoProcessDetailsClientUtils {
transactionHash: plugin?.transactionHash ?? '',
proposalCreationConditionAddress:
plugin?.proposalCreationConditionAddress,
// Carried over so a body flagged as unsupported by the backend does
// not read as supported once rebuilt from the SPP stage settings.
isSupported: plugin?.isSupported,
};
}
}
Expand Down
4 changes: 4 additions & 0 deletions apps/app/src/shared/api/daoService/domain/daoPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,8 @@ export interface IDaoPlugin<
* Address of the create proposal condition of the plugin.
*/
proposalCreationConditionAddress?: string;
/**
* Set to false if a plugin is not installed following standard OSx flow.
*/
isSupported?: boolean;
}
10 changes: 6 additions & 4 deletions apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ export interface IUseDaoPluginsParams {
*/
visibleOnly?: boolean;
/**
* Keeps plugins whose interface type could not be resolved. They are
* dropped by default because the app has no UI to render them with. Set
* this to `true` ONLY for surfaces describing what is installed on-chain
* (permissions, contract versions).
* Keeps plugins the app cannot govern with: those whose interface type could
* not be resolved, and those the backend flags as unsupported (`isSupported:
* false`, e.g. installed outside the standard OSx flow). They are dropped by
* default because the app has no UI to render them with. Set this to `true`
* ONLY for surfaces describing what is installed on-chain (permissions,
* contract versions).
* @default false
*/
includeUnsupported?: boolean;
Expand Down
63 changes: 62 additions & 1 deletion apps/app/src/shared/utils/daoUtils/daoUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ describe('dao utils', () => {
const dao = generateDao({ plugins: daoPlugins });
expect(daoUtils.hasSupportedPlugins(dao)).toBeTruthy();
expect(listContainsRegisteredPluginsSpy).toHaveBeenCalledWith([
PluginInterfaceType.UNKNOWN,
PluginInterfaceType.SPP,
]);
});
Expand All @@ -94,6 +93,19 @@ describe('dao utils', () => {
expect(daoUtils.hasSupportedPlugins(dao)).toBeFalsy();
});

it('ignores plugins flagged as unsupported by the backend', () => {
listContainsRegisteredPluginsSpy.mockReturnValue(false);
const daoPlugins = [
generateDaoPlugin({
interfaceType: PluginInterfaceType.SPP,
isSupported: false,
}),
];
const dao = generateDao({ plugins: daoPlugins });
expect(daoUtils.hasSupportedPlugins(dao)).toBeFalsy();
expect(listContainsRegisteredPluginsSpy).toHaveBeenCalledWith([]);
});

it('returns false when dao parameter is not defined', () => {
listContainsRegisteredPluginsSpy.mockReturnValue(false);
expect(daoUtils.hasSupportedPlugins()).toBeFalsy();
Expand All @@ -115,6 +127,14 @@ describe('dao utils', () => {
expect(daoUtils.isSupportedPlugin(plugin)).toBeFalsy();
});

it('returns false for plugins flagged as unsupported by the backend', () => {
const plugin = generateDaoPlugin({
interfaceType: PluginInterfaceType.MULTISIG,
isSupported: false,
});
expect(daoUtils.isSupportedPlugin(plugin)).toBeFalsy();
});

it('does not use the plugin registry to resolve support', () => {
const plugin = generateDaoPlugin({
interfaceType: PluginInterfaceType.MULTISIG,
Expand Down Expand Up @@ -631,6 +651,47 @@ describe('dao utils', () => {
).toEqual(plugins);
});

it('drops plugins flagged as unsupported by the backend by default', () => {
const plugins = [
generateDaoPlugin({
interfaceType: PluginInterfaceType.MULTISIG,
isSupported: false,
}),
generateDaoPlugin({
interfaceType: PluginInterfaceType.TOKEN_VOTING,
}),
];
const dao = generateDao({ plugins });
expect(daoUtils.getDaoPlugins(dao)).toEqual([plugins[1]]);
});

it('keeps plugins when the backend does not set the isSupported flag', () => {
const plugins = [
generateDaoPlugin({
interfaceType: PluginInterfaceType.MULTISIG,
isSupported: undefined,
}),
];
const dao = generateDao({ plugins });
expect(daoUtils.getDaoPlugins(dao)).toEqual(plugins);
});

it('keeps plugins flagged as unsupported when includeUnsupported is true', () => {
const plugins = [
generateDaoPlugin({
interfaceType: PluginInterfaceType.MULTISIG,
isSupported: false,
}),
generateDaoPlugin({
interfaceType: PluginInterfaceType.TOKEN_VOTING,
}),
];
const dao = generateDao({ plugins });
expect(
daoUtils.getDaoPlugins(dao, { includeUnsupported: true }),
).toEqual(plugins);
});

it('drops unknown plugins for every plugin type, not only processes', () => {
const plugins = [
generateDaoPlugin({
Expand Down
39 changes: 27 additions & 12 deletions apps/app/src/shared/utils/daoUtils/daoUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ export interface IGetDaoPluginsParams {
*/
hasExecute?: boolean;
/**
* Keeps plugins whose interface type could not be resolved. They are
* dropped by default because the app has no UI to render them with. Set
* this to `true` ONLY for surfaces describing what is installed on-chain
* (permissions, contract versions), where omitting a contract would give a
* wrong picture of the DAO.
* Keeps plugins the app cannot govern with: those whose interface type could
* not be resolved, and those the backend flags as unsupported (`isSupported:
* false`, e.g. installed outside the standard OSx flow). They are dropped by
* default because the app has no UI to render them with. Set this to `true`
* ONLY for surfaces describing what is installed on-chain (permissions,
* contract versions), where omitting a contract would give a wrong picture
* of the DAO.
* @default false
*/
includeUnsupported?: boolean;
Expand All @@ -74,21 +76,34 @@ class DaoUtils {
dao?.plugins?.some((p) => p.isBody && this.isSupportedPlugin(p)) ??
false;

/**
* Checks if the DAO has at least one plugin the app can render. Plugins the
* backend cannot resolve or flags as unsupported are dropped first, so this
* stays in sync with what `getDaoPlugins` returns — otherwise sections and
* navigation items would be rendered for a DAO with no usable plugin.
* Client-side only: the plugin registry is populated on demand.
*/
hasSupportedPlugins = (dao?: IDao): boolean => {
const pluginIds =
dao?.plugins?.map(({ interfaceType }) => interfaceType) ?? [];
dao?.plugins
?.filter((plugin) => this.isSupportedPlugin(plugin))
.map(({ interfaceType }) => interfaceType) ?? [];

return pluginRegistryUtils.listContainsRegisteredPlugins(pluginIds);
};

/**
* Checks if the backend could resolve the interface type of the plugin.
* Deliberately based on the interface type and not on the plugin registry:
* the registry is populated on demand, so a registry lookup here would
* report every plugin as unsupported during server rendering.
* Checks if the backend could resolve the interface type of the plugin and
* did not flag it as unsupported (e.g. installed outside the standard OSx
* flow). Deliberately based on those backend fields and not on the plugin
* registry: the registry is populated on demand, so a registry lookup here
* would report every plugin as unsupported during server rendering.
*/
isSupportedPlugin = (plugin: Pick<IDaoPlugin, 'interfaceType'>): boolean =>
plugin.interfaceType !== PluginInterfaceType.UNKNOWN;
isSupportedPlugin = (
plugin: Pick<IDaoPlugin, 'interfaceType' | 'isSupported'>,
): boolean =>
plugin.interfaceType !== PluginInterfaceType.UNKNOWN &&
plugin.isSupported !== false;

getDaoEns = (dao?: IDao): string | undefined =>
dao?.ens != null && dao.ens !== '' ? dao.ens : undefined;
Expand Down
Loading