Skip to content

Commit 0bd1491

Browse files
committed
feat(platform-objects): the identity actions an agent may reach declare it, by risk
Bucket E actions declare ai.exposed with a model-facing description; bucket C actions also declare requiresConfirmation: true; human-only actions carry no ai block. ADR-0011 gains a dated addendum recording the three buckets and the H rule. The MCP dogfood drives one action per bucket with an OAuth-connected agent, and two tests whose premise was "no platform identity action declares ai" follow the declarations. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czk9umSFBVoz5cijzX
1 parent 744ad0a commit 0bd1491

18 files changed

Lines changed: 663 additions & 78 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
feat(platform-objects): the platform identity actions an agent may reach declare it — additive acts exposed, destructive lifecycle acts exposed behind confirmation, credential and ownership acts human-only
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `sys_*` identity objects' actions now carry the ADR-0011 `ai` block by risk, as ADR-0011's 2026-10-30 addendum records (58 actions assessed, 38 declared):
11+
12+
- **Exposed (21).** Additive or self-scoped acts declare `ai: { exposed: true, description }`: the four `invite_user` mirrors, resending and accepting an invitation, adding a member to an organization or a team, creating, editing and renaming a team or an organization, switching the active organization, re-enabling an OAuth application, enabling two-factor authentication, updating your own profile, resending the verification email, and requesting SSO domain verification.
13+
- **Exposed, confirmation required (17).** Destructive or privilege-changing lifecycle acts also declare `requiresConfirmation: true`, so MCP `run_action` refuses them `428 ACTION_CONFIRMATION_REQUIRED`, with nothing run, until the call carries `confirm: true`: banning, unbanning and unlocking a user, setting a user's manager, revoking an API key or a session, signing out other devices, cancelling or declining an invitation, removing a member or a team member, deleting a team, an OAuth application or an SSO provider, disabling an OAuth application, leaving an organization, and unlinking a sign-in provider.
14+
- **Human-only (20).** Acts that mint a credential or a session, change ownership, a role or a sign-in factor, or destroy an account or an organization (impersonating a user, setting a password, creating a user or an API key, transferring ownership, changing a member's role, deleting an organization, and the rest the addendum names) declare no `ai` block and are not listed over MCP. `sys_invitation.copy_invitation_link` declares none either; it was not part of the assessment.
15+
16+
Over MCP these actions are listed and run behind every existing gate: the capability gate, the installation's action switch, the param contract, the confirmation gate and the identity route's own authorization. They are `type: 'api'` actions forwarded to identity routes that accept a signed-in session only, so an agent connected over OAuth sees them listed and is refused at the route when it runs one.

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,20 @@ can reach is the **default exposure policy (v1)**:
261261
The stored-metadata tables (`sys_metadata`, `sys_metadata_history`) stay
262262
refused even when flagged. Each action `list_actions` returns carries
263263
`aiExposed: true`, the server's attestation of that opt-in.
264+
- **The platform's own identity actions declare it by risk.** Additive acts
265+
(inviting someone, adding a member to a team, creating or renaming a team or
266+
an organization, re-enabling an OAuth application) are exposed. Destructive
267+
lifecycle acts (banning a user, revoking a session or an API key, removing a
268+
member, deleting a team, leaving an organization) are exposed with
269+
`ai.requiresConfirmation: true`, so `run_action` refuses them until the call
270+
carries `confirm: true`. Acts that mint a credential or a session, change
271+
ownership or a sign-in factor, or destroy an account or an organization
272+
(impersonating a user, setting a password, creating an API key, transferring
273+
ownership, deleting an organization) declare nothing and are not listed. The
274+
full list is ADR-0011's 2026-10-30 addendum. These actions forward to
275+
identity routes that accept a signed-in session only, so an agent connected
276+
over OAuth sees them listed, but a call that reaches the identity route is
277+
refused there (`401`) and changes nothing.
264278

265279
<Callout type="info">
266280
This is the default policy, not a ceiling. A finer, **metadata-declared** exposure

‎docs/adr/0011-actions-as-ai-tools.md‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,86 @@ ships next.
305305

306306
---
307307

308+
## Addendum (2026-10-30): the platform identity actions an agent may reach
309+
310+
> Status of this addendum: **ruled** (ruling of record `6523087116` on #27900, letter A+), executing
311+
> parameter 2 of ruling `6513869839` on #27681. The maintainer, verbatim:
312+
> 「所以我说评估标准对象的意思,就是允许放开的动作,是不是就应该把申明加上」.
313+
> It changes no rule of this ADR: it records how the platform's own `sys_*` identity actions apply the
314+
> opt-in, and the declarations that follow from it.
315+
316+
The opt-in binds the platform's `sys_*` actions exactly as it binds an app's: an action reaches an
317+
agent only when it declares `ai: { exposed: true, description }`. The named actions on the twelve
318+
identity objects in `packages/platform-objects/src/identity/` were assessed once, as one set, into
319+
three buckets, and the declarations were written in the same change as this addendum.
320+
321+
| Bucket | Test | What the action declares |
322+
|:--|:--|:--|
323+
| **H** human-only | it mints a credential or a session, changes who owns or who can sign in, or destroys an account or an organization | no `ai` block. ⛔ No `ai.exposed` on one of these without a maintainer ruling that names the action. |
324+
| **C** exposed, confirmed | a destructive or privilege-changing lifecycle act that is reversible or bounded | `ai: { exposed: true, description, requiresConfirmation: true }`. ⛔ The confirmation is never relaxed. |
325+
| **E** exposed | an additive or self-scoped act with no credential and no privilege escalation | `ai: { exposed: true, description }`; confirmation is the author's choice (none is declared today). |
326+
327+
**H — human-only (20):** `sys_user.impersonate_user` · `sys_user.set_user_password` ·
328+
`sys_user.create_user` · `sys_user.delete_my_account` · `sys_user.change_my_password` ·
329+
`sys_user.change_my_email` · `sys_user.disable_two_factor` · `sys_user.generate_backup_codes` ·
330+
`sys_two_factor.disable_two_factor` · `sys_two_factor.regenerate_backup_codes` ·
331+
`sys_api_key.create_api_key` · `sys_api_key.restore_api_key` ·
332+
`sys_oauth_application.create_oauth_application` · `sys_oauth_application.rotate_client_secret` ·
333+
`sys_sso_provider.register_sso_provider` · `sys_sso_provider.register_saml_provider` ·
334+
`sys_sso_provider.verify_domain` · `sys_member.transfer_ownership` · `sys_member.update_member_role` ·
335+
`sys_organization.delete_organization`.
336+
337+
**C — exposed, confirmed (17):** `sys_user.ban_user` · `sys_user.unban_user` · `sys_user.unlock_user` ·
338+
`sys_user.set_user_manager` · `sys_api_key.revoke_api_key` · `sys_session.revoke_session` ·
339+
`sys_session.revoke_my_other_sessions` · `sys_invitation.cancel_invitation` ·
340+
`sys_invitation.reject_invitation` · `sys_member.remove_member` · `sys_team_member.remove_team_member` ·
341+
`sys_team.remove_team` · `sys_oauth_application.disable_oauth_application` ·
342+
`sys_oauth_application.delete_oauth_application` · `sys_sso_provider.delete_sso_provider` ·
343+
`sys_organization.leave_organization` · `sys_account.unlink_account`.
344+
345+
**E — exposed (21):** `sys_user.invite_user` · `sys_invitation.invite_user` ·
346+
`sys_organization.invite_user` · `sys_member.invite_user` · `sys_invitation.resend_invitation` ·
347+
`sys_invitation.accept_invitation` · `sys_member.add_member` · `sys_team_member.add_team_member` ·
348+
`sys_team.add_team_member` · `sys_team.create_team` · `sys_team.update_team` ·
349+
`sys_organization.create_organization` · `sys_organization.update_organization` ·
350+
`sys_organization.rename_organization` · `sys_organization.set_active_organization` ·
351+
`sys_oauth_application.enable_oauth_application` · `sys_user.enable_two_factor` ·
352+
`sys_two_factor.enable_two_factor` · `sys_user.update_my_profile` ·
353+
`sys_user.resend_verification_email` · `sys_sso_provider.request_domain_verification`.
354+
355+
That is 58 actions and 38 `ai` blocks. The card's bucket headers read H 19 and E 22; its name lists,
356+
which the ruling adopted, hold 20 and 21, and the names are the record.
357+
358+
**Not assessed.** `sys_invitation.copy_invitation_link` was added after the assessment (the link-only
359+
invitation delivery). It declares no `ai` block, which is this ADR's default, and it is not assessed
360+
until a maintainer ruling names it.
361+
362+
**No automatic check.** The maintainer struck the pin test the assessment proposed:
363+
「自动检查 有必要吗?每一个元数据应用都有类似的问题,后续都要检查吗」. This table lives only here; a reviewer
364+
of `packages/platform-objects/src/identity/**` reads it. A customer app governs its own actions with
365+
the same key and the platform's defaults: the opt-in, the confirmation default, and the ADR-0126
366+
activation switch.
367+
368+
**How the declarations reach an agent.** Over MCP, a `sys_*` action is listed and run only when the
369+
action itself declares `ai.exposed: true` — ADR-0066 D4's 2026-10-30 note records that mechanism
370+
(`packages/runtime/src/action-execution.ts#systemObjectActionExposureError`). As of this addendum the
371+
38 E and C actions above are the platform `sys_*` actions that declare it. Every gate after admission
372+
still runs: ADR-0066 D4's capability gate, the ADR-0126 §8 activation switch, the
373+
`ai.requiresConfirmation` gate (a C action is refused `428 ACTION_CONFIRMATION_REQUIRED`, nothing run,
374+
until the call carries `confirm: true`; `packages/runtime/src/action-execution.ts#actionConfirmationRefusal`),
375+
and the target's own authorization.
376+
377+
**Declared is not yet runnable for an OAuth-connected agent.** All 38 are `type: 'api'` actions that
378+
forward to an identity route: 37 to the better-auth routes under `/api/v1/auth/`, and
379+
`sys_api_key.revoke_api_key` to the REST data route. Those targets accept a signed-in session, and the
380+
forward of an OAuth-connected agent's call is answered `401` there. Which credentials a forward
381+
target accepts is not decided here; it is the open decision on #28178. Until that lands, an
382+
OAuth-connected agent lists these actions, a C action is still refused `428` until the call is
383+
confirmed, and a call that reaches the identity route is answered `401` there, with nothing changed.
384+
A signed-in session's same call is served.
385+
386+
---
387+
308388
## References
309389

310390
* ADR-0003 — Package as first-class citizen (where actions live)

‎packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,13 @@ describe('#7309 — an action that collects params opens ONE dialog', () => {
105105
// `ai.description` is the LLM-facing contract (≥40 chars, required when
106106
// `ai.exposed`) — the same word one level down. Putting the question there
107107
// would arm a tool description while the dialog fell back to its generic line.
108+
// An action ADR-0011's 2026-10-30 addendum exposes carries its own
109+
// `ai.description`, written for the model — never the dialog question.
108110
for (const [name, obj] of OBJECTS) {
109111
for (const action of CONVERTED[name] ?? []) {
110-
expect(byName(obj, action).ai?.description, `${name}.${action}.ai.description`).toBeUndefined();
112+
const a = byName(obj, action);
113+
expect(a.description, `${name}.${action}.description`).toBeTruthy();
114+
expect(a.ai?.description, `${name}.${action}.ai.description`).not.toBe(a.description);
111115
}
112116
}
113117
});

‎packages/platform-objects/src/identity/sys-account.object.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ export const SysAccount = ObjectSchema.create({
7474
params: [
7575
{ name: 'accountId', field: 'id', defaultFromRow: true, required: true },
7676
],
77+
// ADR-0011 identity-actions note, bucket C: exposed, and the
78+
// confirmation is never relaxed.
79+
ai: {
80+
exposed: true,
81+
description:
82+
'Unlink an external sign-in provider from the user\'s account (the record; param: its accountId): they can no longer sign in with that provider until they link it again. Ask the human to confirm before calling.',
83+
requiresConfirmation: true,
84+
},
7785
},
7886
],
7987

‎packages/platform-objects/src/identity/sys-api-key.object.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,14 @@ export const SysApiKey = ObjectSchema.create({
160160
confirmText: 'Revoke this API key? Any clients using it will immediately lose access.',
161161
successMessage: 'API key "{{ record.name }}" revoked',
162162
refreshAfter: true,
163+
// ADR-0011 identity-actions note, bucket C: exposed, and the
164+
// confirmation is never relaxed.
165+
ai: {
166+
exposed: true,
167+
description:
168+
'Revoke an API key (the record): every client using it loses access immediately. It can be restored later. Ask the human to confirm before calling.',
169+
requiresConfirmation: true,
170+
},
163171
},
164172
{
165173
name: 'restore_api_key',

‎packages/platform-objects/src/identity/sys-invitation.object.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ export const SysInvitation = ObjectSchema.create({
106106
// field, so the gate narrows this dialog and no other picker.
107107
{ field: 'role', required: true, options: [...INVITATION_ROLE_OPTIONS], helpText: MEMBERSHIP_ROLE_PARAM_HELP },
108108
],
109+
// ADR-0011 identity-actions note, bucket E: exposed by plain declaration.
110+
ai: {
111+
exposed: true,
112+
description:
113+
'Invite a person by email address to join the current organization with a role (params: email, role). They join when they accept the invitation. Use when the user asks to invite someone who is not a member yet; the role options are only the roles the caller may grant.',
114+
},
109115
},
110116
{
111117
name: 'cancel_invitation',
@@ -122,6 +128,14 @@ export const SysInvitation = ObjectSchema.create({
122128
confirmText: 'Cancel this invitation? The recipient will no longer be able to accept it.',
123129
successMessage: 'Invitation canceled',
124130
refreshAfter: true,
131+
// ADR-0011 identity-actions note, bucket C: exposed, and the
132+
// confirmation is never relaxed.
133+
ai: {
134+
exposed: true,
135+
description:
136+
'Cancel a pending invitation (the record), so its recipient can no longer accept it. Ask the human to confirm before calling.',
137+
requiresConfirmation: true,
138+
},
125139
},
126140
{
127141
name: 'resend_invitation',
@@ -152,6 +166,12 @@ export const SysInvitation = ObjectSchema.create({
152166
{ field: 'email', required: true, defaultFromRow: true, carryOver: true },
153167
{ field: 'role', required: true, defaultFromRow: true, carryOver: true },
154168
],
169+
// ADR-0011 identity-actions note, bucket E: exposed by plain declaration.
170+
ai: {
171+
exposed: true,
172+
description:
173+
'Send a pending invitation (the record) again (params: its email and role, copied from the invitation; the stored role is kept). Its expiry date starts over. Use when the invitee lost the invitation or never received it.',
174+
},
155175
},
156176
{
157177
// [#27843, ruling E on #27797] The pending invitation's accept link, for
@@ -216,6 +236,12 @@ export const SysInvitation = ObjectSchema.create({
216236
visible: "has(record.email) && record.email == ctx.user.email && has(record.status) && record.status == 'pending'",
217237
successMessage: 'Invitation accepted',
218238
refreshAfter: true,
239+
// ADR-0011 identity-actions note, bucket E: exposed by plain declaration.
240+
ai: {
241+
exposed: true,
242+
description:
243+
'Accept an invitation addressed to the current user (the record), joining its organization with the invited role. Works only on a pending invitation sent to the caller\'s own email address.',
244+
},
219245
},
220246
{
221247
name: 'reject_invitation',
@@ -230,6 +256,14 @@ export const SysInvitation = ObjectSchema.create({
230256
confirmText: 'Decline this invitation? The inviter will be notified and you will need a new invitation to join.',
231257
successMessage: 'Invitation declined',
232258
refreshAfter: true,
259+
// ADR-0011 identity-actions note, bucket C: exposed, and the
260+
// confirmation is never relaxed.
261+
ai: {
262+
exposed: true,
263+
description:
264+
'Decline an invitation addressed to the current user (the record). The inviter is notified, and joining later needs a new invitation. Ask the human to confirm before calling.',
265+
requiresConfirmation: true,
266+
},
233267
},
234268
],
235269

‎packages/platform-objects/src/identity/sys-member.object.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ export const SysMember = ObjectSchema.create({
130130
// field, so the gate narrows this dialog and no other picker.
131131
{ field: 'role', required: true, options: [...INVITATION_ROLE_OPTIONS], helpText: MEMBERSHIP_ROLE_PARAM_HELP },
132132
],
133+
// ADR-0011 identity-actions note, bucket E: exposed by plain declaration.
134+
ai: {
135+
exposed: true,
136+
description:
137+
'Invite a person by email address to join the current organization with a role (params: email, role). They join when they accept the invitation. Use when the user asks to invite someone who is not a member yet; the role options are only the roles the caller may grant.',
138+
},
133139
},
134140
{
135141
// Admin-only: directly attach an existing user to the active org,
@@ -200,6 +206,12 @@ export const SysMember = ObjectSchema.create({
200206
{ field: 'role', required: true, helpText: MEMBERSHIP_ROLE_PARAM_HELP },
201207
{ name: 'organizationId', field: 'organization_id' },
202208
],
209+
// ADR-0011 identity-actions note, bucket E: exposed by plain declaration.
210+
ai: {
211+
exposed: true,
212+
description:
213+
'Add an existing user straight into an organization with a role, without an invitation (params: userId, role, and optionally organizationId, which defaults to the active organization). Platform administrators only.',
214+
},
203215
},
204216
{
205217
name: 'update_member_role',
@@ -245,6 +257,14 @@ export const SysMember = ObjectSchema.create({
245257
confirmText: 'Remove this member from the organization? They will lose access to all org resources.',
246258
successMessage: 'Member removed',
247259
refreshAfter: true,
260+
// ADR-0011 identity-actions note, bucket C: exposed, and the
261+
// confirmation is never relaxed.
262+
ai: {
263+
exposed: true,
264+
description:
265+
'Remove a member (the record) from the organization: they lose access to all of its resources. Ask the human to confirm before calling.',
266+
requiresConfirmation: true,
267+
},
248268
},
249269
// Make Owner is `update-member-role` with role=owner, and better-auth's
250270
// `updateMemberRole` writes the TARGET's role and nothing else: the caller

‎packages/platform-objects/src/identity/sys-oauth-application.object.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ export const SysOauthApplication = ObjectSchema.create({
113113
params: [
114114
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },
115115
],
116+
// ADR-0011 identity-actions note, bucket C: exposed, and the
117+
// confirmation is never relaxed.
118+
ai: {
119+
exposed: true,
120+
description:
121+
'Disable an OAuth application (the record; param: its client_id): its tokens are rejected and integrations using it stop working until it is re-enabled. Platform administrators only. Ask the human to confirm before calling.',
122+
requiresConfirmation: true,
123+
},
116124
},
117125
{
118126
name: 'enable_oauth_application',
@@ -134,6 +142,12 @@ export const SysOauthApplication = ObjectSchema.create({
134142
params: [
135143
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },
136144
],
145+
// ADR-0011 identity-actions note, bucket E: exposed by plain declaration.
146+
ai: {
147+
exposed: true,
148+
description:
149+
'Re-enable a disabled OAuth application (the record; param: its client_id), so token issuance, authorization and introspection resume for that client. Platform administrators only.',
150+
},
137151
},
138152
{
139153
name: 'create_oauth_application',
@@ -246,6 +260,14 @@ export const SysOauthApplication = ObjectSchema.create({
246260
params: [
247261
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },
248262
],
263+
// ADR-0011 identity-actions note, bucket C: exposed, and the
264+
// confirmation is never relaxed.
265+
ai: {
266+
exposed: true,
267+
description:
268+
'Permanently delete an OAuth application (the record; param: its client_id): all tokens and consents issued to it are invalidated and its integrations stop working. This cannot be undone. Ask the human to confirm before calling.',
269+
requiresConfirmation: true,
270+
},
249271
},
250272
],
251273

0 commit comments

Comments
 (0)