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
16 changes: 16 additions & 0 deletions .changeset/27900-identity-actions-ai-exposure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/platform-objects': patch
---

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

Clause-②: no
Metadata-protocol: none

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):

- **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.
- **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.
- **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.

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.
14 changes: 14 additions & 0 deletions content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,20 @@ can reach is the **default exposure policy (v1)**:
The stored-metadata tables (`sys_metadata`, `sys_metadata_history`) stay
refused even when flagged. Each action `list_actions` returns carries
`aiExposed: true`, the server's attestation of that opt-in.
- **The platform's own identity actions declare it by risk.** Additive acts
(inviting someone, adding a member to a team, creating or renaming a team or
an organization, re-enabling an OAuth application) are exposed. Destructive
lifecycle acts (banning a user, revoking a session or an API key, removing a
member, deleting a team, leaving an organization) are exposed with
`ai.requiresConfirmation: true`, so `run_action` refuses them until the call
carries `confirm: true`. Acts that mint a credential or a session, change
ownership or a sign-in factor, or destroy an account or an organization
(impersonating a user, setting a password, creating an API key, transferring
ownership, deleting an organization) declare nothing and are not listed. The
full list is ADR-0011's 2026-10-30 addendum. These actions forward to
identity routes that accept a signed-in session only, so an agent connected
over OAuth sees them listed, but a call that reaches the identity route is
refused there (`401`) and changes nothing.

<Callout type="info">
This is the default policy, not a ceiling. A finer, **metadata-declared** exposure
Expand Down
80 changes: 80 additions & 0 deletions docs/adr/0011-actions-as-ai-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,86 @@ ships next.

---

## Addendum (2026-10-30): the platform identity actions an agent may reach

> Status of this addendum: **ruled** (ruling of record `6523087116` on #27900, letter A+), executing
> parameter 2 of ruling `6513869839` on #27681. The maintainer, verbatim:
> 「所以我说评估标准对象的意思,就是允许放开的动作,是不是就应该把申明加上」.
> It changes no rule of this ADR: it records how the platform's own `sys_*` identity actions apply the
> opt-in, and the declarations that follow from it.

The opt-in binds the platform's `sys_*` actions exactly as it binds an app's: an action reaches an
agent only when it declares `ai: { exposed: true, description }`. The named actions on the twelve
identity objects in `packages/platform-objects/src/identity/` were assessed once, as one set, into
three buckets, and the declarations were written in the same change as this addendum.

| Bucket | Test | What the action declares |
|:--|:--|:--|
| **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. |
| **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. |
| **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). |

**H — human-only (20):** `sys_user.impersonate_user` · `sys_user.set_user_password` ·
`sys_user.create_user` · `sys_user.delete_my_account` · `sys_user.change_my_password` ·
`sys_user.change_my_email` · `sys_user.disable_two_factor` · `sys_user.generate_backup_codes` ·
`sys_two_factor.disable_two_factor` · `sys_two_factor.regenerate_backup_codes` ·
`sys_api_key.create_api_key` · `sys_api_key.restore_api_key` ·
`sys_oauth_application.create_oauth_application` · `sys_oauth_application.rotate_client_secret` ·
`sys_sso_provider.register_sso_provider` · `sys_sso_provider.register_saml_provider` ·
`sys_sso_provider.verify_domain` · `sys_member.transfer_ownership` · `sys_member.update_member_role` ·
`sys_organization.delete_organization`.

**C — exposed, confirmed (17):** `sys_user.ban_user` · `sys_user.unban_user` · `sys_user.unlock_user` ·
`sys_user.set_user_manager` · `sys_api_key.revoke_api_key` · `sys_session.revoke_session` ·
`sys_session.revoke_my_other_sessions` · `sys_invitation.cancel_invitation` ·
`sys_invitation.reject_invitation` · `sys_member.remove_member` · `sys_team_member.remove_team_member` ·
`sys_team.remove_team` · `sys_oauth_application.disable_oauth_application` ·
`sys_oauth_application.delete_oauth_application` · `sys_sso_provider.delete_sso_provider` ·
`sys_organization.leave_organization` · `sys_account.unlink_account`.

**E — exposed (21):** `sys_user.invite_user` · `sys_invitation.invite_user` ·
`sys_organization.invite_user` · `sys_member.invite_user` · `sys_invitation.resend_invitation` ·
`sys_invitation.accept_invitation` · `sys_member.add_member` · `sys_team_member.add_team_member` ·
`sys_team.add_team_member` · `sys_team.create_team` · `sys_team.update_team` ·
`sys_organization.create_organization` · `sys_organization.update_organization` ·
`sys_organization.rename_organization` · `sys_organization.set_active_organization` ·
`sys_oauth_application.enable_oauth_application` · `sys_user.enable_two_factor` ·
`sys_two_factor.enable_two_factor` · `sys_user.update_my_profile` ·
`sys_user.resend_verification_email` · `sys_sso_provider.request_domain_verification`.

That is 58 actions and 38 `ai` blocks. The card's bucket headers read H 19 and E 22; its name lists,
which the ruling adopted, hold 20 and 21, and the names are the record.

**Not assessed.** `sys_invitation.copy_invitation_link` was added after the assessment (the link-only
invitation delivery). It declares no `ai` block, which is this ADR's default, and it is not assessed
until a maintainer ruling names it.

**No automatic check.** The maintainer struck the pin test the assessment proposed:
「自动检查 有必要吗?每一个元数据应用都有类似的问题,后续都要检查吗」. This table lives only here; a reviewer
of `packages/platform-objects/src/identity/**` reads it. A customer app governs its own actions with
the same key and the platform's defaults: the opt-in, the confirmation default, and the ADR-0126
activation switch.

**How the declarations reach an agent.** Over MCP, a `sys_*` action is listed and run only when the
action itself declares `ai.exposed: true` — ADR-0066 D4's 2026-10-30 note records that mechanism
(`packages/runtime/src/action-execution.ts#systemObjectActionExposureError`). As of this addendum the
38 E and C actions above are the platform `sys_*` actions that declare it. Every gate after admission
still runs: ADR-0066 D4's capability gate, the ADR-0126 §8 activation switch, the
`ai.requiresConfirmation` gate (a C action is refused `428 ACTION_CONFIRMATION_REQUIRED`, nothing run,
until the call carries `confirm: true`; `packages/runtime/src/action-execution.ts#actionConfirmationRefusal`),
and the target's own authorization.

**Declared is not yet runnable for an OAuth-connected agent.** All 38 are `type: 'api'` actions that
forward to an identity route: 37 to the better-auth routes under `/api/v1/auth/`, and
`sys_api_key.revoke_api_key` to the REST data route. Those targets accept a signed-in session, and the
forward of an OAuth-connected agent's call is answered `401` there. Which credentials a forward
target accepts is not decided here; it is the open decision on #28178. Until that lands, an
OAuth-connected agent lists these actions, a C action is still refused `428` until the call is
confirmed, and a call that reaches the identity route is answered `401` there, with nothing changed.
A signed-in session's same call is served.

---

## References

* ADR-0003 — Package as first-class citizen (where actions live)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,13 @@ describe('#7309 — an action that collects params opens ONE dialog', () => {
// `ai.description` is the LLM-facing contract (≥40 chars, required when
// `ai.exposed`) — the same word one level down. Putting the question there
// would arm a tool description while the dialog fell back to its generic line.
// An action ADR-0011's 2026-10-30 addendum exposes carries its own
// `ai.description`, written for the model — never the dialog question.
for (const [name, obj] of OBJECTS) {
for (const action of CONVERTED[name] ?? []) {
expect(byName(obj, action).ai?.description, `${name}.${action}.ai.description`).toBeUndefined();
const a = byName(obj, action);
expect(a.description, `${name}.${action}.description`).toBeTruthy();
expect(a.ai?.description, `${name}.${action}.ai.description`).not.toBe(a.description);
}
}
});
Expand Down
8 changes: 8 additions & 0 deletions packages/platform-objects/src/identity/sys-account.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ export const SysAccount = ObjectSchema.create({
params: [
{ name: 'accountId', field: 'id', defaultFromRow: true, required: true },
],
// ADR-0011 identity-actions note, bucket C: exposed, and the
// confirmation is never relaxed.
ai: {
exposed: true,
description:
'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.',
requiresConfirmation: true,
},
},
],

Expand Down
8 changes: 8 additions & 0 deletions packages/platform-objects/src/identity/sys-api-key.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,14 @@ export const SysApiKey = ObjectSchema.create({
confirmText: 'Revoke this API key? Any clients using it will immediately lose access.',
successMessage: 'API key "{{ record.name }}" revoked',
refreshAfter: true,
// ADR-0011 identity-actions note, bucket C: exposed, and the
// confirmation is never relaxed.
ai: {
exposed: true,
description:
'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.',
requiresConfirmation: true,
},
},
{
name: 'restore_api_key',
Expand Down
34 changes: 34 additions & 0 deletions packages/platform-objects/src/identity/sys-invitation.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ export const SysInvitation = ObjectSchema.create({
// field, so the gate narrows this dialog and no other picker.
{ field: 'role', required: true, options: [...INVITATION_ROLE_OPTIONS], helpText: MEMBERSHIP_ROLE_PARAM_HELP },
],
// ADR-0011 identity-actions note, bucket E: exposed by plain declaration.
ai: {
exposed: true,
description:
'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.',
},
},
{
name: 'cancel_invitation',
Expand All @@ -122,6 +128,14 @@ export const SysInvitation = ObjectSchema.create({
confirmText: 'Cancel this invitation? The recipient will no longer be able to accept it.',
successMessage: 'Invitation canceled',
refreshAfter: true,
// ADR-0011 identity-actions note, bucket C: exposed, and the
// confirmation is never relaxed.
ai: {
exposed: true,
description:
'Cancel a pending invitation (the record), so its recipient can no longer accept it. Ask the human to confirm before calling.',
requiresConfirmation: true,
},
},
{
name: 'resend_invitation',
Expand Down Expand Up @@ -152,6 +166,12 @@ export const SysInvitation = ObjectSchema.create({
{ field: 'email', required: true, defaultFromRow: true, carryOver: true },
{ field: 'role', required: true, defaultFromRow: true, carryOver: true },
],
// ADR-0011 identity-actions note, bucket E: exposed by plain declaration.
ai: {
exposed: true,
description:
'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.',
},
},
{
// [#27843, ruling E on #27797] The pending invitation's accept link, for
Expand Down Expand Up @@ -216,6 +236,12 @@ export const SysInvitation = ObjectSchema.create({
visible: "has(record.email) && record.email == ctx.user.email && has(record.status) && record.status == 'pending'",
successMessage: 'Invitation accepted',
refreshAfter: true,
// ADR-0011 identity-actions note, bucket E: exposed by plain declaration.
ai: {
exposed: true,
description:
'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.',
},
},
{
name: 'reject_invitation',
Expand All @@ -230,6 +256,14 @@ export const SysInvitation = ObjectSchema.create({
confirmText: 'Decline this invitation? The inviter will be notified and you will need a new invitation to join.',
successMessage: 'Invitation declined',
refreshAfter: true,
// ADR-0011 identity-actions note, bucket C: exposed, and the
// confirmation is never relaxed.
ai: {
exposed: true,
description:
'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.',
requiresConfirmation: true,
},
},
],

Expand Down
20 changes: 20 additions & 0 deletions packages/platform-objects/src/identity/sys-member.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ export const SysMember = ObjectSchema.create({
// field, so the gate narrows this dialog and no other picker.
{ field: 'role', required: true, options: [...INVITATION_ROLE_OPTIONS], helpText: MEMBERSHIP_ROLE_PARAM_HELP },
],
// ADR-0011 identity-actions note, bucket E: exposed by plain declaration.
ai: {
exposed: true,
description:
'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.',
},
},
{
// Admin-only: directly attach an existing user to the active org,
Expand Down Expand Up @@ -200,6 +206,12 @@ export const SysMember = ObjectSchema.create({
{ field: 'role', required: true, helpText: MEMBERSHIP_ROLE_PARAM_HELP },
{ name: 'organizationId', field: 'organization_id' },
],
// ADR-0011 identity-actions note, bucket E: exposed by plain declaration.
ai: {
exposed: true,
description:
'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.',
},
},
{
name: 'update_member_role',
Expand Down Expand Up @@ -245,6 +257,14 @@ export const SysMember = ObjectSchema.create({
confirmText: 'Remove this member from the organization? They will lose access to all org resources.',
successMessage: 'Member removed',
refreshAfter: true,
// ADR-0011 identity-actions note, bucket C: exposed, and the
// confirmation is never relaxed.
ai: {
exposed: true,
description:
'Remove a member (the record) from the organization: they lose access to all of its resources. Ask the human to confirm before calling.',
requiresConfirmation: true,
},
},
// Make Owner is `update-member-role` with role=owner, and better-auth's
// `updateMemberRole` writes the TARGET's role and nothing else: the caller
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ export const SysOauthApplication = ObjectSchema.create({
params: [
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },
],
// ADR-0011 identity-actions note, bucket C: exposed, and the
// confirmation is never relaxed.
ai: {
exposed: true,
description:
'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.',
requiresConfirmation: true,
},
},
{
name: 'enable_oauth_application',
Expand All @@ -134,6 +142,12 @@ export const SysOauthApplication = ObjectSchema.create({
params: [
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },
],
// ADR-0011 identity-actions note, bucket E: exposed by plain declaration.
ai: {
exposed: true,
description:
'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.',
},
},
{
name: 'create_oauth_application',
Expand Down Expand Up @@ -246,6 +260,14 @@ export const SysOauthApplication = ObjectSchema.create({
params: [
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },
],
// ADR-0011 identity-actions note, bucket C: exposed, and the
// confirmation is never relaxed.
ai: {
exposed: true,
description:
'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.',
requiresConfirmation: true,
},
},
],

Expand Down
Loading
Loading