From efb20afa136cede0609fe18fafd0e21eff274f46 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 24 Aug 2026 20:26:44 -0600 Subject: [PATCH 1/5] feat(backend): Add AgentAction resource types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 1 of the clerk.policy surface: the wire interfaces, resource classes, and deserializer arms for agent actions. No consumer yet — the PolicyAPI endpoint class that returns these lands in the next layer. --- .changeset/olive-pugs-smile.md | 5 + .changeset/quiet-toys-invite.md | 7 + .../backend/src/api/resources/AgentAction.ts | 235 ++++++++++++++ .../backend/src/api/resources/AgentTask.ts | 2 + .../backend/src/api/resources/Deserializer.ts | 7 + packages/backend/src/api/resources/Enums.ts | 32 +- packages/backend/src/api/resources/JSON.ts | 98 ++++++ .../resources/__tests__/AgentAction.test.ts | 296 ++++++++++++++++++ packages/backend/src/api/resources/index.ts | 4 + packages/backend/src/index.ts | 21 +- packages/shared/src/types/agentActions.ts | 10 + packages/shared/src/types/index.ts | 1 + 12 files changed, 716 insertions(+), 2 deletions(-) create mode 100644 .changeset/olive-pugs-smile.md create mode 100644 .changeset/quiet-toys-invite.md create mode 100644 packages/backend/src/api/resources/AgentAction.ts create mode 100644 packages/backend/src/api/resources/__tests__/AgentAction.test.ts create mode 100644 packages/shared/src/types/agentActions.ts diff --git a/.changeset/olive-pugs-smile.md b/.changeset/olive-pugs-smile.md new file mode 100644 index 00000000000..cca4b09d0d2 --- /dev/null +++ b/.changeset/olive-pugs-smile.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +Add the experimental `AgentActionStatus` type, the lifecycle status of an agent action awaiting a policy decision. It is declared here so that `@clerk/backend` and the approval review surface share one definition rather than each carrying their own. diff --git a/.changeset/quiet-toys-invite.md b/.changeset/quiet-toys-invite.md new file mode 100644 index 00000000000..d39b83f9f70 --- /dev/null +++ b/.changeset/quiet-toys-invite.md @@ -0,0 +1,7 @@ +--- +'@clerk/backend': minor +--- + +Add experimental `AgentAction`, `AgentActionDecision`, and `AgentActionStatus` resource types, describing an agent operation that was checked against a policy, what the policy decided, and how a human resolved it. These are exported as types only; the `clerk.policy` methods that return them ship separately. + +Branch control flow on `AgentAction.status`, never on `decision.effect` — an action that could not be routed to a reviewer is created with a `denied` status while its decision still reads `require_approval`. diff --git a/packages/backend/src/api/resources/AgentAction.ts b/packages/backend/src/api/resources/AgentAction.ts new file mode 100644 index 00000000000..4dc7b845569 --- /dev/null +++ b/packages/backend/src/api/resources/AgentAction.ts @@ -0,0 +1,235 @@ +import type { AgentActionEffect, AgentActionEvaluation, AgentActionStatusValue } from './Enums'; +import type { AgentActionDecisionJSON, AgentActionJSON, AgentActionStatusJSON } from './JSON'; + +/** + * One row of the reviewer-facing rendering of an Agent Action's parameters. `value` is + * formatted server-side from the field registry's declared display format. + * + * @experimental This is an experimental API and is subject to change. + */ +export type AgentActionParametersDisplay = { + /** The parameter key, in the exact spelling the check was made with. */ + key: string; + /** The human-readable label declared for this key in the field registry. */ + label: string; + /** The formatted value shown to the reviewer. Always a scalar — non-scalar leaves are omitted from the projection. */ + value: string | number | boolean; +}; + +/** + * A single rule that could not be evaluated. Present only when `evaluation` is `'error'`. + * + * @experimental This is an experimental API and is subject to change. + */ +export type AgentActionEvaluationError = { + /** The id of the rule that failed to evaluate. */ + ruleId: string; + /** The reason the rule was skipped. */ + message: string; +}; + +/** + * The approval stamp applied when an Agent Action is created `pending`. It survives + * resolution as an audit record, so do not infer liveness from it — `status` is + * authoritative once the action is terminal. + * + * @experimental This is an experimental API and is subject to change. + */ +export type AgentActionApproval = { + /** The organization role key routed to review this action, or `null` when the subject is the reviewer. */ + role: string | null; + /** The URL a human visits to review the action. Derived at serialization time from the instance's current accounts host, so it is not stable across an accounts-domain change. */ + url: string; + /** The Unix timestamp (in milliseconds) when the approval window closes. */ + expiresAt: number; +}; + +/** + * Who answered a pending Agent Action and when. The answer itself is carried by + * `status` (`approved` or `rejected`), not by this block. + * + * @experimental This is an experimental API and is subject to change. + */ +export type AgentActionResolution = { + /** The ID of the user who resolved the action. */ + resolvedByUserId: string; + /** The Unix timestamp (in milliseconds) when the action was resolved. */ + resolvedAt: number; + /** The reviewer's comment, visible to the application but never to the agent. */ + resolutionComment: string | null; +}; + +/** + * The Backend `AgentActionDecision` object is the immutable record of what the policy + * engine decided for an Agent Action, written once and never updated. The human's answer + * lives on the [`AgentAction`](#agentaction) as a resolution; the engine's lives here as + * an effect. + * + * @experimental This is an experimental API and is subject to change. + */ +export class AgentActionDecision { + constructor( + /** The unique identifier for the decision. */ + readonly id: string, + /** The ID of the Agent Action this decision was made for. */ + readonly actionId: string, + /** The ID of the policy that decided. `null` when no active policy existed. */ + readonly policyId: string | null, + /** The revision of the policy document that decided. `null` exactly when `policyId` is `null`. */ + readonly policyRevision: number | null, + /** The author-chosen ID of the deciding rule. `null` when the policy's `default_effect` decided. */ + readonly ruleId: string | null, + /** What the engine decided. Never branch control flow on this — see [`AgentAction.status`](#agentaction). */ + readonly effect: AgentActionEffect, + /** The deny rule's stated reason, or, on a fail-closed downgrade, the engine-generated cause naming the missing party or unresolvable role. */ + readonly reason: string | null, + /** Whether the policy document evaluated cleanly. */ + readonly evaluation: AgentActionEvaluation, + /** The rules that were skipped, in priority order. Non-`null` exactly when `evaluation` is `'error'`. */ + readonly evaluationErrors: AgentActionEvaluationError[] | null, + /** The Unix timestamp (in milliseconds) when the decision was recorded. */ + readonly createdAt: number, + ) {} + + static fromJSON(data: AgentActionDecisionJSON): AgentActionDecision { + return new AgentActionDecision( + data.id, + data.action_id, + data.policy_id, + data.policy_revision, + data.rule_id, + data.effect, + data.reason, + data.evaluation, + data.evaluation_errors?.map(error => ({ ruleId: error.rule_id, message: error.message })) ?? null, + data.created_at, + ); + } +} + +/** + * The Backend `AgentAction` object represents one policy-checked operation an agent + * attempted on a person's behalf, together with what the policy decided and, if a human + * was asked, how they answered. Every `check()` creates one, including operations the + * policy immediately allows. + * + * Not to be confused with [`AgentTask`](#agenttask), an unrelated session-creation + * affordance that only sorts next to this object alphabetically. + * + * @experimental This is an experimental API and is subject to change. + */ +export class AgentAction { + constructor( + /** The unique identifier for the Agent Action. */ + readonly id: string, + /** + * Where the action stands. `pending` is the only non-terminal value, and this is the + * only field control flow should branch on: a fail-closed downgrade produces + * `'denied'` here alongside a `decision.effect` of `'require_approval'`, so a caller + * keying off the effect would wait forever on an action that was denied at creation. + */ + readonly status: AgentActionStatusValue, + /** The identifier of the agent that attempted the operation. */ + readonly actorId: string, + /** The ID of the user the agent was acting on behalf of. */ + readonly subjectId: string | null, + /** The ID of the organization the operation was scoped to. */ + readonly organizationId: string | null, + /** The ID of the OAuth application the agent was authorized through. A display and audit snapshot only; no policy rule addresses it. */ + readonly authorizedClientId: string | null, + /** The operation being attempted, matching the `operation` a policy rule and a field-registry row address. */ + readonly operation: string, + /** + * The operation's arguments, exactly as they were sent. Keys are never normalized or + * camel-cased in either direction, because the spelling used here is the spelling the + * field registry and every policy leaf must use. + */ + readonly parameters: Record, + /** The application-authored context shown to the reviewer. */ + readonly description: string | null, + /** The reviewer-facing rendering of `parameters`, limited to the keys declared in the field registry. */ + readonly parametersDisplay: AgentActionParametersDisplay[], + /** The content-bound deduplication key the action was created with, echoed so a caller can tell a replay from a fresh create. */ + readonly idempotencyKey: string | null, + /** The approval stamp. Non-`null` if and only if the action was created `pending`, and it survives resolution. */ + readonly approval: AgentActionApproval | null, + /** Who resolved the action and when. `null` until a human answers. */ + readonly resolution: AgentActionResolution | null, + /** What the policy engine decided. */ + readonly decision: AgentActionDecision, + /** The Unix timestamp (in milliseconds) when the Agent Action was created. */ + readonly createdAt: number, + /** The Unix timestamp (in milliseconds) when the Agent Action was last updated. */ + readonly updatedAt: number, + ) {} + + static fromJSON(data: AgentActionJSON): AgentAction { + return new AgentAction( + data.id, + data.status, + data.actor_id, + data.subject_id, + data.organization_id, + data.authorized_client_id, + data.operation, + data.parameters, + data.description, + data.parameters_display.map(entry => ({ key: entry.key, label: entry.label, value: entry.value })), + data.idempotency_key, + data.approval && { role: data.approval.role, url: data.approval.url, expiresAt: data.approval.expires_at }, + data.resolution && { + resolvedByUserId: data.resolution.resolved_by_user_id, + resolvedAt: data.resolution.resolved_at, + resolutionComment: data.resolution.resolution_comment, + }, + AgentActionDecision.fromJSON(data.decision), + data.created_at, + data.updated_at, + ); + } +} + +/** + * The Backend `AgentActionStatus` object is the slim view of an Agent Action returned by + * the resolution-delivery endpoint — the fields that change while a human decides. The + * reviewer's identity and comment are deliberately absent, because this response flows + * into an agent's context; read them from [`AgentAction`](#agentaction) instead. + * + * @experimental This is an experimental API and is subject to change. + */ +export class AgentActionStatus { + constructor( + /** The ID of the Agent Action this status describes. */ + readonly actionId: string, + /** Where the action stands. Loop while this is `pending`; act when it is anything else. */ + readonly status: AgentActionStatusValue, + /** What the policy engine decided. */ + readonly effect: AgentActionEffect, + /** The deny rule's stated reason, or the engine-generated cause of a fail-closed downgrade. */ + readonly reason: string | null, + /** Whether the policy document evaluated cleanly. */ + readonly evaluation: AgentActionEvaluation, + /** The rules that were skipped, in priority order. Non-`null` exactly when `evaluation` is `'error'`. */ + readonly evaluationErrors: AgentActionEvaluationError[] | null, + /** The Unix timestamp (in milliseconds) when the approval window closes. `null` when the action was never pending. */ + readonly expiresAt: number | null, + /** The Unix timestamp (in milliseconds) when the action was resolved. `null` until then. */ + readonly resolvedAt: number | null, + /** The Unix timestamp (in milliseconds) when the Agent Action was created. */ + readonly createdAt: number, + ) {} + + static fromJSON(data: AgentActionStatusJSON): AgentActionStatus { + return new AgentActionStatus( + data.action_id, + data.status, + data.effect, + data.reason, + data.evaluation, + data.evaluation_errors?.map(error => ({ ruleId: error.rule_id, message: error.message })) ?? null, + data.expires_at, + data.resolved_at, + data.created_at, + ); + } +} diff --git a/packages/backend/src/api/resources/AgentTask.ts b/packages/backend/src/api/resources/AgentTask.ts index 95017849655..19abda02cbf 100644 --- a/packages/backend/src/api/resources/AgentTask.ts +++ b/packages/backend/src/api/resources/AgentTask.ts @@ -2,6 +2,8 @@ import type { AgentTaskJSON } from './JSON'; /** * The Backend `AgentTask` object represents an Agent Task resource. Agent Tasks are used for testing purposes and allow creating sessions for users without requiring full authentication flows. + * + * Not to be confused with [`AgentAction`](#agentaction), an unrelated record of an agent operation awaiting a policy decision. */ export class AgentTask { constructor( diff --git a/packages/backend/src/api/resources/Deserializer.ts b/packages/backend/src/api/resources/Deserializer.ts index b51e9dc5ca8..9b7795a178f 100644 --- a/packages/backend/src/api/resources/Deserializer.ts +++ b/packages/backend/src/api/resources/Deserializer.ts @@ -1,5 +1,7 @@ import { ActorToken, + AgentAction, + AgentActionStatus, AgentTask, AllowlistIdentifier, APIKey, @@ -144,6 +146,11 @@ function jsonToObject(item: any): any { return AccountlessApplication.fromJSON(item); case ObjectType.ActorToken: return ActorToken.fromJSON(item); + case ObjectType.AgentAction: + return AgentAction.fromJSON(item); + // No AgentActionDecision arm: a decision only ever arrives embedded in an agent_action. + case ObjectType.AgentActionStatus: + return AgentActionStatus.fromJSON(item); case ObjectType.AllowlistIdentifier: return AllowlistIdentifier.fromJSON(item); case ObjectType.ApiKey: diff --git a/packages/backend/src/api/resources/Enums.ts b/packages/backend/src/api/resources/Enums.ts index 019bc3458a7..08f3449bfab 100644 --- a/packages/backend/src/api/resources/Enums.ts +++ b/packages/backend/src/api/resources/Enums.ts @@ -1,4 +1,4 @@ -import type { OrganizationCustomRoleKey } from '@clerk/shared/types'; +import type { AgentActionStatus as SharedAgentActionStatus, OrganizationCustomRoleKey } from '@clerk/shared/types'; export type OAuthProvider = | 'facebook' @@ -65,3 +65,33 @@ export type BlocklistIdentifierType = AllowlistIdentifierType; /** @inline */ export type WaitlistEntryStatus = 'pending' | 'invited' | 'completed' | 'rejected'; + +/** + * The lifecycle status of an Agent Action. `pending` is the only non-terminal value. + * + * Aliased rather than redeclared: the union is owned by `@clerk/shared` so this package + * and the approval review surface cannot drift. The local name disambiguates it from the + * `AgentActionStatus` resource class, which is the slim status view, not a status value. + * + * @experimental This is an experimental API and is subject to change. + * @inline + */ +export type AgentActionStatusValue = SharedAgentActionStatus; + +/** + * What the policy engine decided. Distinct from the lifecycle status: a fail-closed + * downgrade produces `require_approval` on an Agent Action whose status is `denied`. + * + * @experimental This is an experimental API and is subject to change. + * @inline + */ +export type AgentActionEffect = 'allow' | 'deny' | 'require_approval'; + +/** + * Whether the policy document evaluated cleanly. `error` means at least one rule + * was skipped, and the reasons are listed in `evaluationErrors`. + * + * @experimental This is an experimental API and is subject to change. + * @inline + */ +export type AgentActionEvaluation = 'ok' | 'error'; diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index e1ff98e2ee1..6a33e8c44fa 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -10,6 +10,9 @@ import type { import type { ActorTokenStatus, + AgentActionEffect, + AgentActionEvaluation, + AgentActionStatusValue, AllowlistIdentifierType, BlocklistIdentifierType, DomainsEnrollmentModes, @@ -27,6 +30,9 @@ import type { export const ObjectType = { AccountlessApplication: 'accountless_application', ActorToken: 'actor_token', + AgentAction: 'agent_action', + AgentActionDecision: 'agent_action_decision', + AgentActionStatus: 'agent_action_status', AgentTask: 'agent_task', AllowlistIdentifier: 'allowlist_identifier', ApiKey: 'api_key', @@ -598,6 +604,98 @@ export interface SignInTokenJSON extends ClerkResourceJSON { updated_at: number; } +/** + * @experimental This is an experimental API and is subject to change. + */ +export interface AgentActionParametersDisplayJSON { + key: string; + label: string; + value: string | number | boolean; +} + +/** + * @experimental This is an experimental API and is subject to change. + */ +export interface AgentActionEvaluationErrorJSON { + rule_id: string; + message: string; +} + +/** + * @experimental This is an experimental API and is subject to change. + */ +export interface AgentActionApprovalJSON { + role: string | null; + url: string; + expires_at: number; +} + +/** + * @experimental This is an experimental API and is subject to change. + */ +export interface AgentActionResolutionJSON { + resolved_by_user_id: string; + resolved_at: number; + resolution_comment: string | null; +} + +/** + * @experimental This is an experimental API and is subject to change. + */ +export interface AgentActionDecisionJSON extends ClerkResourceJSON { + object: typeof ObjectType.AgentActionDecision; + action_id: string; + policy_id: string | null; + policy_revision: number | null; + rule_id: string | null; + effect: AgentActionEffect; + reason: string | null; + evaluation: AgentActionEvaluation; + evaluation_errors: AgentActionEvaluationErrorJSON[] | null; + created_at: number; +} + +/** + * @experimental This is an experimental API and is subject to change. + */ +export interface AgentActionJSON extends ClerkResourceJSON { + object: typeof ObjectType.AgentAction; + status: AgentActionStatusValue; + actor_id: string; + subject_id: string | null; + organization_id: string | null; + authorized_client_id: string | null; + operation: string; + parameters: Record; + description: string | null; + parameters_display: AgentActionParametersDisplayJSON[]; + idempotency_key: string | null; + approval: AgentActionApprovalJSON | null; + resolution: AgentActionResolutionJSON | null; + decision: AgentActionDecisionJSON; + created_at: number; + updated_at: number; +} + +/** + * The slim status view returned by the resolution-delivery endpoint. It carries no + * `id` of its own: it is a projection of the Agent Action named by `action_id`. + * + * @experimental This is an experimental API and is subject to change. + */ +export interface AgentActionStatusJSON { + object: typeof ObjectType.AgentActionStatus; + action_id: string; + status: AgentActionStatusValue; + effect: AgentActionEffect; + reason: string | null; + evaluation: AgentActionEvaluation; + evaluation_errors: AgentActionEvaluationErrorJSON[] | null; + expires_at: number | null; + resolved_at: number | null; + created_at: number; +} + export interface AgentTaskJSON extends ClerkResourceJSON { object: typeof ObjectType.AgentTask; agent_id: string; diff --git a/packages/backend/src/api/resources/__tests__/AgentAction.test.ts b/packages/backend/src/api/resources/__tests__/AgentAction.test.ts new file mode 100644 index 00000000000..fa2f8a0b324 --- /dev/null +++ b/packages/backend/src/api/resources/__tests__/AgentAction.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; + +import { AgentAction, AgentActionDecision, AgentActionStatus } from '../AgentAction'; +import { deserialize } from '../Deserializer'; +import type { AgentActionDecisionJSON, AgentActionJSON, AgentActionStatusJSON } from '../JSON'; + +const decisionJSON: AgentActionDecisionJSON = { + object: 'agent_action_decision', + id: 'agtactdec_2mL', + action_id: 'agtact_2mK', + policy_id: 'pol_2a8', + policy_revision: 4, + rule_id: 'rule_large_refund_approval', + effect: 'require_approval', + reason: null, + evaluation: 'ok', + evaluation_errors: null, + created_at: 1755655200000, +}; + +const actionJSON: AgentActionJSON = { + object: 'agent_action', + id: 'agtact_2mK', + status: 'pending', + actor_id: 'https://codex.example.com/.well-known/cimd.json', + subject_id: 'user_2h9K', + organization_id: 'org_2bT', + authorized_client_id: null, + operation: 'api/v1/refund', + parameters: { refund_amount: 25000, currency: 'usd', customer_id: 'cus_9x' }, + description: "Refund the customer's duplicate charge", + parameters_display: [ + { key: 'refund_amount', label: 'Refund amount', value: '$250.00' }, + { key: 'customer_id', label: 'Customer', value: 'cus_9x' }, + ], + idempotency_key: 'chg_dup_8821-refund', + approval: { + role: 'org:support_manager', + url: 'https://accounts.example.com/action-approval/agtact_2mK', + expires_at: 1755741600000, + }, + resolution: null, + decision: decisionJSON, + created_at: 1755655200000, + updated_at: 1755655200000, +}; + +describe('AgentAction', () => { + describe('fromJSON', () => { + it('maps every field of a pending action', () => { + const action = AgentAction.fromJSON(actionJSON); + + expect(action).toBeInstanceOf(AgentAction); + expect(action.id).toBe('agtact_2mK'); + expect(action.status).toBe('pending'); + expect(action.actorId).toBe('https://codex.example.com/.well-known/cimd.json'); + expect(action.subjectId).toBe('user_2h9K'); + expect(action.organizationId).toBe('org_2bT'); + expect(action.authorizedClientId).toBeNull(); + expect(action.operation).toBe('api/v1/refund'); + expect(action.description).toBe("Refund the customer's duplicate charge"); + expect(action.idempotencyKey).toBe('chg_dup_8821-refund'); + expect(action.createdAt).toBe(1755655200000); + expect(action.updatedAt).toBe(1755655200000); + }); + + it('passes parameters through verbatim, without normalizing key spelling', () => { + const action = AgentAction.fromJSON({ + ...actionJSON, + parameters: { refundAmount: 25000, refund_amount: 100, 'Customer-Id': 'cus_9x', nested: { keepMe: true } }, + }); + + // The spelling used at check time is the spelling the field registry and every + // policy leaf must use, so camelCasing here would silently unmatch a rule. + expect(action.parameters).toEqual({ + refundAmount: 25000, + refund_amount: 100, + 'Customer-Id': 'cus_9x', + nested: { keepMe: true }, + }); + expect(Object.keys(action.parameters)).toEqual(['refundAmount', 'refund_amount', 'Customer-Id', 'nested']); + }); + + it('passes each parametersDisplay key through verbatim, in both spellings', () => { + const action = AgentAction.fromJSON({ + ...actionJSON, + parameters_display: [ + // A snake_case key is the direction that matters: the package camelCases + // everything else, and this is the one field §3.2 forbids it on. + { key: 'refund_amount', label: 'Refund amount', value: '$250.00' }, + { key: 'refundAmount', label: 'Refund amount (raw)', value: 25000 }, + { key: 'Customer-Id', label: 'Customer', value: 'cus_9x' }, + ], + }); + + expect(action.parametersDisplay).toEqual([ + { key: 'refund_amount', label: 'Refund amount', value: '$250.00' }, + { key: 'refundAmount', label: 'Refund amount (raw)', value: 25000 }, + { key: 'Customer-Id', label: 'Customer', value: 'cus_9x' }, + ]); + }); + + it('maps the approval block and leaves an unresolved action without a resolution', () => { + const action = AgentAction.fromJSON(actionJSON); + + expect(action.approval).toEqual({ + role: 'org:support_manager', + url: 'https://accounts.example.com/action-approval/agtact_2mK', + expiresAt: 1755741600000, + }); + expect(action.resolution).toBeNull(); + }); + + it('maps the resolution block once a human has answered', () => { + const action = AgentAction.fromJSON({ + ...actionJSON, + status: 'approved', + resolution: { + resolved_by_user_id: 'user_2mR', + resolved_at: 1755658800000, + resolution_comment: 'Duplicate confirmed.', + }, + }); + + expect(action.status).toBe('approved'); + expect(action.resolution).toEqual({ + resolvedByUserId: 'user_2mR', + resolvedAt: 1755658800000, + resolutionComment: 'Duplicate confirmed.', + }); + }); + + it('keeps status and decision.effect on separate axes for a fail-closed downgrade', () => { + const action = AgentAction.fromJSON({ + ...actionJSON, + status: 'denied', + subject_id: null, + approval: null, + decision: { ...decisionJSON, effect: 'require_approval', reason: 'missing_subject_for_approval' }, + }); + + // The five-part wire signature from api-contracts-v1 §3. + expect(action.status).toBe('denied'); + expect(action.approval).toBeNull(); + expect(action.decision.effect).toBe('require_approval'); + expect(action.decision.evaluation).toBe('ok'); + expect(action.decision.reason).toBe('missing_subject_for_approval'); + }); + + it('constructs the embedded decision as an AgentActionDecision', () => { + const action = AgentAction.fromJSON(actionJSON); + + expect(action.decision).toBeInstanceOf(AgentActionDecision); + expect(action.decision.id).toBe('agtactdec_2mL'); + expect(action.decision.actionId).toBe('agtact_2mK'); + }); + }); +}); + +describe('AgentActionDecision', () => { + describe('fromJSON', () => { + it('maps a decision made against an active policy', () => { + const decision = AgentActionDecision.fromJSON(decisionJSON); + + expect(decision.policyId).toBe('pol_2a8'); + expect(decision.policyRevision).toBe(4); + expect(decision.ruleId).toBe('rule_large_refund_approval'); + expect(decision.effect).toBe('require_approval'); + expect(decision.evaluation).toBe('ok'); + expect(decision.evaluationErrors).toBeNull(); + expect(decision.createdAt).toBe(1755655200000); + }); + + it('maps the no-active-policy decision, where the policy pair is null', () => { + const decision = AgentActionDecision.fromJSON({ + ...decisionJSON, + policy_id: null, + policy_revision: null, + rule_id: null, + effect: 'allow', + }); + + expect(decision.policyId).toBeNull(); + expect(decision.policyRevision).toBeNull(); + expect(decision.ruleId).toBeNull(); + expect(decision.effect).toBe('allow'); + }); + + it('maps evaluation errors in priority order', () => { + const decision = AgentActionDecision.fromJSON({ + ...decisionJSON, + evaluation: 'error', + evaluation_errors: [ + { rule_id: 'rule_a', message: 'unknown field parameters.refundAmount' }, + { rule_id: 'rule_b', message: 'operator not valid for type' }, + ], + }); + + expect(decision.evaluation).toBe('error'); + expect(decision.evaluationErrors).toEqual([ + { ruleId: 'rule_a', message: 'unknown field parameters.refundAmount' }, + { ruleId: 'rule_b', message: 'operator not valid for type' }, + ]); + }); + }); +}); + +describe('AgentActionStatus', () => { + const statusJSON: AgentActionStatusJSON = { + object: 'agent_action_status', + action_id: 'agtact_2mK', + status: 'approved', + effect: 'require_approval', + reason: null, + evaluation: 'ok', + evaluation_errors: null, + expires_at: 1755741600000, + resolved_at: 1755658800000, + created_at: 1755655200000, + }; + + describe('fromJSON', () => { + it('maps every field', () => { + const status = AgentActionStatus.fromJSON(statusJSON); + + expect(status).toBeInstanceOf(AgentActionStatus); + expect(status.actionId).toBe('agtact_2mK'); + expect(status.status).toBe('approved'); + expect(status.effect).toBe('require_approval'); + expect(status.reason).toBeNull(); + expect(status.evaluation).toBe('ok'); + expect(status.evaluationErrors).toBeNull(); + expect(status.expiresAt).toBe(1755741600000); + expect(status.resolvedAt).toBe(1755658800000); + expect(status.createdAt).toBe(1755655200000); + }); + + it('leaves resolvedAt null while the action is pending', () => { + const status = AgentActionStatus.fromJSON({ ...statusJSON, status: 'pending', resolved_at: null }); + + expect(status.status).toBe('pending'); + expect(status.resolvedAt).toBeNull(); + }); + + it('maps evaluation errors', () => { + const status = AgentActionStatus.fromJSON({ + ...statusJSON, + evaluation: 'error', + evaluation_errors: [{ rule_id: 'rule_a', message: 'unknown field' }], + }); + + expect(status.evaluationErrors).toEqual([{ ruleId: 'rule_a', message: 'unknown field' }]); + }); + }); +}); + +describe('deserialize', () => { + it('routes an agent_action payload to AgentAction', () => { + const { data } = deserialize(actionJSON); + + expect(data).toBeInstanceOf(AgentAction); + expect(data.id).toBe('agtact_2mK'); + }); + + it('routes an agent_action_status payload to AgentActionStatus', () => { + const { data } = deserialize({ + object: 'agent_action_status', + action_id: 'agtact_2mK', + status: 'pending', + effect: 'require_approval', + reason: null, + evaluation: 'ok', + evaluation_errors: null, + expires_at: 1755741600000, + resolved_at: null, + created_at: 1755655200000, + } satisfies AgentActionStatusJSON); + + expect(data).toBeInstanceOf(AgentActionStatus); + expect(data.actionId).toBe('agtact_2mK'); + }); + + it('routes a list of agent_action payloads', () => { + const { data } = deserialize({ data: [actionJSON], total_count: 1 }); + + expect(data).toHaveLength(1); + expect(data[0]).toBeInstanceOf(AgentAction); + }); + + it('has no arm for a top-level agent_action_decision, which the contract never returns', () => { + const { data } = deserialize(decisionJSON); + + expect(data).not.toBeInstanceOf(AgentActionDecision); + expect(data).toBe(decisionJSON); + }); +}); diff --git a/packages/backend/src/api/resources/index.ts b/packages/backend/src/api/resources/index.ts index 128882ba241..dd4b661652b 100644 --- a/packages/backend/src/api/resources/index.ts +++ b/packages/backend/src/api/resources/index.ts @@ -1,4 +1,5 @@ export * from './AccountlessApplication'; +export * from './AgentAction'; export * from './AgentTask'; export * from './ActorToken'; export * from './AllowlistIdentifier'; @@ -13,6 +14,9 @@ export * from './Email'; export * from './EmailAddress'; export type { + AgentActionEffect, + AgentActionEvaluation, + AgentActionStatusValue, InvitationStatus, OAuthProvider, OAuthStrategy, diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index c418fcc3a0a..27d8cfeff3d 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -48,13 +48,25 @@ export function createClerkClient(options: ClerkOptions): ClerkClient { /** * General Types */ -export type { OrganizationMembershipRole } from './api/resources'; +export type { + AgentActionEffect, + AgentActionEvaluation, + AgentActionStatusValue, + OrganizationMembershipRole, +} from './api/resources'; export type { VerifyTokenOptions } from './tokens/verify'; /** * JSON types */ export type { ActorTokenJSON, + AgentActionApprovalJSON, + AgentActionDecisionJSON, + AgentActionEvaluationErrorJSON, + AgentActionJSON, + AgentActionParametersDisplayJSON, + AgentActionResolutionJSON, + AgentActionStatusJSON, AgentTaskJSON, AccountlessApplicationJSON, ClerkResourceJSON, @@ -120,6 +132,13 @@ export type { * Resources */ export type { + AgentAction, + AgentActionApproval, + AgentActionDecision, + AgentActionEvaluationError, + AgentActionParametersDisplay, + AgentActionResolution, + AgentActionStatus, AgentTask, APIKey, ActorToken, diff --git a/packages/shared/src/types/agentActions.ts b/packages/shared/src/types/agentActions.ts new file mode 100644 index 00000000000..d4297f9e73a --- /dev/null +++ b/packages/shared/src/types/agentActions.ts @@ -0,0 +1,10 @@ +/** + * The lifecycle status of an Agent Action. `pending` is the only non-terminal value. + * + * Declared here rather than in a consuming package because both `@clerk/backend`'s + * resources and the approval review surface project the same column. + * + * @experimental This is an experimental API and is subject to change. + * @inline + */ +export type AgentActionStatus = 'pending' | 'allowed' | 'denied' | 'approved' | 'rejected' | 'expired'; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 577a38ab18d..029db479fca 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,3 +1,4 @@ +export type * from './agentActions'; export type * from './apiKeys'; export type * from './apiKeysSettings'; export type * from './attributes'; From e8c151ee94d65b8bb4dff84608c4e11ee4d79368 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 24 Aug 2026 23:17:17 -0600 Subject: [PATCH 2/5] fix(backend,shared): address review on AgentAction resource types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @experimental tags carry the version-pin recommendation, matching the agentTasks and billing pattern impl-sdk.md §8 names - cross-resource JSDoc links use {@link} rather than same-page anchors, which resolved to nothing because each type gets its own docs page - approval and resolution map to null rather than undefined when the key is absent, and parameters_display tolerates omission - the evaluation-errors mapping is shared by both fromJSON implementations - tests cover the approval block surviving resolution, a never-pending expiresAt, an absent evaluation_errors, and a deny decision --- .../backend/src/api/resources/AgentAction.ts | 62 ++++++++++++------- .../backend/src/api/resources/AgentTask.ts | 2 +- packages/backend/src/api/resources/Enums.ts | 6 +- packages/backend/src/api/resources/JSON.ts | 14 ++--- .../resources/__tests__/AgentAction.test.ts | 44 +++++++++++++ packages/shared/src/types/agentActions.ts | 2 +- 6 files changed, 95 insertions(+), 35 deletions(-) diff --git a/packages/backend/src/api/resources/AgentAction.ts b/packages/backend/src/api/resources/AgentAction.ts index 4dc7b845569..7bfd0297887 100644 --- a/packages/backend/src/api/resources/AgentAction.ts +++ b/packages/backend/src/api/resources/AgentAction.ts @@ -1,11 +1,16 @@ import type { AgentActionEffect, AgentActionEvaluation, AgentActionStatusValue } from './Enums'; -import type { AgentActionDecisionJSON, AgentActionJSON, AgentActionStatusJSON } from './JSON'; +import type { + AgentActionDecisionJSON, + AgentActionEvaluationErrorJSON, + AgentActionJSON, + AgentActionStatusJSON, +} from './JSON'; /** * One row of the reviewer-facing rendering of an Agent Action's parameters. `value` is * formatted server-side from the field registry's declared display format. * - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export type AgentActionParametersDisplay = { /** The parameter key, in the exact spelling the check was made with. */ @@ -19,7 +24,7 @@ export type AgentActionParametersDisplay = { /** * A single rule that could not be evaluated. Present only when `evaluation` is `'error'`. * - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export type AgentActionEvaluationError = { /** The id of the rule that failed to evaluate. */ @@ -28,12 +33,22 @@ export type AgentActionEvaluationError = { message: string; }; +/** + * Both the decision record and the slim status view carry the same skipped-rule list, so + * the wire-to-camelCase mapping and the "absent means `null`" rule live in one place. + */ +function toEvaluationErrors( + data: AgentActionEvaluationErrorJSON[] | null | undefined, +): AgentActionEvaluationError[] | null { + return data?.map(error => ({ ruleId: error.rule_id, message: error.message })) ?? null; +} + /** * The approval stamp applied when an Agent Action is created `pending`. It survives * resolution as an audit record, so do not infer liveness from it — `status` is * authoritative once the action is terminal. * - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export type AgentActionApproval = { /** The organization role key routed to review this action, or `null` when the subject is the reviewer. */ @@ -48,7 +63,7 @@ export type AgentActionApproval = { * Who answered a pending Agent Action and when. The answer itself is carried by * `status` (`approved` or `rejected`), not by this block. * - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export type AgentActionResolution = { /** The ID of the user who resolved the action. */ @@ -62,10 +77,9 @@ export type AgentActionResolution = { /** * The Backend `AgentActionDecision` object is the immutable record of what the policy * engine decided for an Agent Action, written once and never updated. The human's answer - * lives on the [`AgentAction`](#agentaction) as a resolution; the engine's lives here as - * an effect. + * lives on the {@link AgentAction} as a resolution; the engine's lives here as an effect. * - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export class AgentActionDecision { constructor( @@ -79,7 +93,7 @@ export class AgentActionDecision { readonly policyRevision: number | null, /** The author-chosen ID of the deciding rule. `null` when the policy's `default_effect` decided. */ readonly ruleId: string | null, - /** What the engine decided. Never branch control flow on this — see [`AgentAction.status`](#agentaction). */ + /** What the engine decided. Never branch control flow on this — branch on `status` on the {@link AgentAction} instead. */ readonly effect: AgentActionEffect, /** The deny rule's stated reason, or, on a fail-closed downgrade, the engine-generated cause naming the missing party or unresolvable role. */ readonly reason: string | null, @@ -101,7 +115,7 @@ export class AgentActionDecision { data.effect, data.reason, data.evaluation, - data.evaluation_errors?.map(error => ({ ruleId: error.rule_id, message: error.message })) ?? null, + toEvaluationErrors(data.evaluation_errors), data.created_at, ); } @@ -113,10 +127,10 @@ export class AgentActionDecision { * was asked, how they answered. Every `check()` creates one, including operations the * policy immediately allows. * - * Not to be confused with [`AgentTask`](#agenttask), an unrelated session-creation - * affordance that only sorts next to this object alphabetically. + * Not to be confused with {@link AgentTask}, an unrelated session-creation affordance that + * only sorts next to this object alphabetically. * - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export class AgentAction { constructor( @@ -174,14 +188,16 @@ export class AgentAction { data.operation, data.parameters, data.description, - data.parameters_display.map(entry => ({ key: entry.key, label: entry.label, value: entry.value })), + (data.parameters_display ?? []).map(entry => ({ key: entry.key, label: entry.label, value: entry.value })), data.idempotency_key, - data.approval && { role: data.approval.role, url: data.approval.url, expiresAt: data.approval.expires_at }, - data.resolution && { - resolvedByUserId: data.resolution.resolved_by_user_id, - resolvedAt: data.resolution.resolved_at, - resolutionComment: data.resolution.resolution_comment, - }, + data.approval ? { role: data.approval.role, url: data.approval.url, expiresAt: data.approval.expires_at } : null, + data.resolution + ? { + resolvedByUserId: data.resolution.resolved_by_user_id, + resolvedAt: data.resolution.resolved_at, + resolutionComment: data.resolution.resolution_comment, + } + : null, AgentActionDecision.fromJSON(data.decision), data.created_at, data.updated_at, @@ -193,9 +209,9 @@ export class AgentAction { * The Backend `AgentActionStatus` object is the slim view of an Agent Action returned by * the resolution-delivery endpoint — the fields that change while a human decides. The * reviewer's identity and comment are deliberately absent, because this response flows - * into an agent's context; read them from [`AgentAction`](#agentaction) instead. + * into an agent's context; read them from {@link AgentAction} instead. * - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export class AgentActionStatus { constructor( @@ -226,7 +242,7 @@ export class AgentActionStatus { data.effect, data.reason, data.evaluation, - data.evaluation_errors?.map(error => ({ ruleId: error.rule_id, message: error.message })) ?? null, + toEvaluationErrors(data.evaluation_errors), data.expires_at, data.resolved_at, data.created_at, diff --git a/packages/backend/src/api/resources/AgentTask.ts b/packages/backend/src/api/resources/AgentTask.ts index 19abda02cbf..8f5f76392ab 100644 --- a/packages/backend/src/api/resources/AgentTask.ts +++ b/packages/backend/src/api/resources/AgentTask.ts @@ -3,7 +3,7 @@ import type { AgentTaskJSON } from './JSON'; /** * The Backend `AgentTask` object represents an Agent Task resource. Agent Tasks are used for testing purposes and allow creating sessions for users without requiring full authentication flows. * - * Not to be confused with [`AgentAction`](#agentaction), an unrelated record of an agent operation awaiting a policy decision. + * Not to be confused with {@link AgentAction}, an unrelated record of an agent operation awaiting a policy decision. */ export class AgentTask { constructor( diff --git a/packages/backend/src/api/resources/Enums.ts b/packages/backend/src/api/resources/Enums.ts index 08f3449bfab..4425998d8ed 100644 --- a/packages/backend/src/api/resources/Enums.ts +++ b/packages/backend/src/api/resources/Enums.ts @@ -73,7 +73,7 @@ export type WaitlistEntryStatus = 'pending' | 'invited' | 'completed' | 'rejecte * and the approval review surface cannot drift. The local name disambiguates it from the * `AgentActionStatus` resource class, which is the slim status view, not a status value. * - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. * @inline */ export type AgentActionStatusValue = SharedAgentActionStatus; @@ -82,7 +82,7 @@ export type AgentActionStatusValue = SharedAgentActionStatus; * What the policy engine decided. Distinct from the lifecycle status: a fail-closed * downgrade produces `require_approval` on an Agent Action whose status is `denied`. * - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. * @inline */ export type AgentActionEffect = 'allow' | 'deny' | 'require_approval'; @@ -91,7 +91,7 @@ export type AgentActionEffect = 'allow' | 'deny' | 'require_approval'; * Whether the policy document evaluated cleanly. `error` means at least one rule * was skipped, and the reasons are listed in `evaluationErrors`. * - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. * @inline */ export type AgentActionEvaluation = 'ok' | 'error'; diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 6a33e8c44fa..a84ad87d83c 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -605,7 +605,7 @@ export interface SignInTokenJSON extends ClerkResourceJSON { } /** - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export interface AgentActionParametersDisplayJSON { key: string; @@ -614,7 +614,7 @@ export interface AgentActionParametersDisplayJSON { } /** - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export interface AgentActionEvaluationErrorJSON { rule_id: string; @@ -622,7 +622,7 @@ export interface AgentActionEvaluationErrorJSON { } /** - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export interface AgentActionApprovalJSON { role: string | null; @@ -631,7 +631,7 @@ export interface AgentActionApprovalJSON { } /** - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export interface AgentActionResolutionJSON { resolved_by_user_id: string; @@ -640,7 +640,7 @@ export interface AgentActionResolutionJSON { } /** - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export interface AgentActionDecisionJSON extends ClerkResourceJSON { object: typeof ObjectType.AgentActionDecision; @@ -656,7 +656,7 @@ export interface AgentActionDecisionJSON extends ClerkResourceJSON { } /** - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export interface AgentActionJSON extends ClerkResourceJSON { object: typeof ObjectType.AgentAction; @@ -681,7 +681,7 @@ export interface AgentActionJSON extends ClerkResourceJSON { * The slim status view returned by the resolution-delivery endpoint. It carries no * `id` of its own: it is a projection of the Agent Action named by `action_id`. * - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export interface AgentActionStatusJSON { object: typeof ObjectType.AgentActionStatus; diff --git a/packages/backend/src/api/resources/__tests__/AgentAction.test.ts b/packages/backend/src/api/resources/__tests__/AgentAction.test.ts index fa2f8a0b324..aa2cdcf9b87 100644 --- a/packages/backend/src/api/resources/__tests__/AgentAction.test.ts +++ b/packages/backend/src/api/resources/__tests__/AgentAction.test.ts @@ -128,6 +128,14 @@ describe('AgentAction', () => { resolvedAt: 1755658800000, resolutionComment: 'Duplicate confirmed.', }); + // api-contracts-v1 §3: the approval block survives resolution, and `expires_at` + // becomes historical rather than being cleared. `status` is what says the action + // is terminal. + expect(action.approval).toEqual({ + role: 'org:support_manager', + url: 'https://accounts.example.com/action-approval/agtact_2mK', + expiresAt: 1755741600000, + }); }); it('keeps status and decision.effect on separate axes for a fail-closed downgrade', () => { @@ -186,6 +194,19 @@ describe('AgentActionDecision', () => { expect(decision.effect).toBe('allow'); }); + it('maps a deny decision, where the reason is the rule author’s', () => { + const decision = AgentActionDecision.fromJSON({ + ...decisionJSON, + rule_id: 'rule_refunds_over_limit', + effect: 'deny', + reason: 'Refunds above $100 are not delegated to agents.', + }); + + expect(decision.effect).toBe('deny'); + expect(decision.ruleId).toBe('rule_refunds_over_limit'); + expect(decision.reason).toBe('Refunds above $100 are not delegated to agents.'); + }); + it('maps evaluation errors in priority order', () => { const decision = AgentActionDecision.fromJSON({ ...decisionJSON, @@ -242,6 +263,20 @@ describe('AgentActionStatus', () => { expect(status.resolvedAt).toBeNull(); }); + it('leaves expiresAt null for an action that was never pending', () => { + const status = AgentActionStatus.fromJSON({ + ...statusJSON, + status: 'allowed', + effect: 'allow', + expires_at: null, + resolved_at: null, + }); + + expect(status.status).toBe('allowed'); + expect(status.expiresAt).toBeNull(); + expect(status.resolvedAt).toBeNull(); + }); + it('maps evaluation errors', () => { const status = AgentActionStatus.fromJSON({ ...statusJSON, @@ -251,6 +286,15 @@ describe('AgentActionStatus', () => { expect(status.evaluationErrors).toEqual([{ ruleId: 'rule_a', message: 'unknown field' }]); }); + + it('maps an absent evaluation_errors to null, not undefined', () => { + // The contract always sends the key, so this asserts the mapper's own floor: a + // caller checking `evaluationErrors === null` must not be defeated by an omission. + const { evaluation_errors: _omitted, ...withoutErrors } = statusJSON; + const status = AgentActionStatus.fromJSON(withoutErrors as AgentActionStatusJSON); + + expect(status.evaluationErrors).toBeNull(); + }); }); }); diff --git a/packages/shared/src/types/agentActions.ts b/packages/shared/src/types/agentActions.ts index d4297f9e73a..ca748145758 100644 --- a/packages/shared/src/types/agentActions.ts +++ b/packages/shared/src/types/agentActions.ts @@ -4,7 +4,7 @@ * Declared here rather than in a consuming package because both `@clerk/backend`'s * resources and the approval review surface project the same column. * - * @experimental This is an experimental API and is subject to change. + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. * @inline */ export type AgentActionStatus = 'pending' | 'allowed' | 'denied' | 'approved' | 'rejected' | 'expired'; From 538bd7cc691acce0739ab3777b2138e3b04e9b72 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 24 Aug 2026 23:50:52 -0600 Subject: [PATCH 3/5] fix(shared): satisfy jsdoc/tag-lines on AgentActionStatus --- packages/shared/src/types/agentActions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared/src/types/agentActions.ts b/packages/shared/src/types/agentActions.ts index ca748145758..f311d85944e 100644 --- a/packages/shared/src/types/agentActions.ts +++ b/packages/shared/src/types/agentActions.ts @@ -5,6 +5,7 @@ * resources and the approval review surface project the same column. * * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + * * @inline */ export type AgentActionStatus = 'pending' | 'allowed' | 'denied' | 'approved' | 'rejected' | 'expired'; From 40541b8558414a757e547ef6a98e03dd2c2e1e14 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 25 Aug 2026 00:06:40 -0600 Subject: [PATCH 4/5] refactor(backend): model AgentAction nested objects as resource classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert `AgentActionApproval`, `AgentActionResolution`, and `AgentActionEvaluationError` from plain object types to classes with their own `fromJSON`, matching how every other nested wire object in `resources/` is modelled (`SessionActivity`, `IdentificationLink`, `Verification`). Each type's snake-to-camel mapping now sits beside its declaration instead of inlined into the `AgentAction` constructor call, and both ternaries there collapse to the `data.x && Class.fromJSON(data.x)` shape used elsewhere in the package. `AgentActionParametersDisplay` stays a plain type: its wire and domain shapes are identical, so a class would need an identity `fromJSON`. For the same reason `parameters_display` now passes through by reference rather than being rebuilt field by field — the wire type declares it neither optional nor nullable, and passing it untouched is the most literal expression of the rule that a display key's spelling is never transformed. Also documents the status-versus-effect split on `AgentActionStatus.effect`, where a polling caller is most likely to reach for the wrong field, and trims five multi-line comment blocks to single lines per the repo's comment rule. No behaviour change; the existing tests pass unmodified. --- .../backend/src/api/resources/AgentAction.ts | 85 +++++++++++-------- .../resources/__tests__/AgentAction.test.ts | 13 +-- 2 files changed, 52 insertions(+), 46 deletions(-) diff --git a/packages/backend/src/api/resources/AgentAction.ts b/packages/backend/src/api/resources/AgentAction.ts index 7bfd0297887..dc5973746f9 100644 --- a/packages/backend/src/api/resources/AgentAction.ts +++ b/packages/backend/src/api/resources/AgentAction.ts @@ -1,8 +1,10 @@ import type { AgentActionEffect, AgentActionEvaluation, AgentActionStatusValue } from './Enums'; import type { + AgentActionApprovalJSON, AgentActionDecisionJSON, AgentActionEvaluationErrorJSON, AgentActionJSON, + AgentActionResolutionJSON, AgentActionStatusJSON, } from './JSON'; @@ -26,21 +28,24 @@ export type AgentActionParametersDisplay = { * * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ -export type AgentActionEvaluationError = { - /** The id of the rule that failed to evaluate. */ - ruleId: string; - /** The reason the rule was skipped. */ - message: string; -}; +export class AgentActionEvaluationError { + constructor( + /** The id of the rule that failed to evaluate. */ + readonly ruleId: string, + /** The reason the rule was skipped. */ + readonly message: string, + ) {} -/** - * Both the decision record and the slim status view carry the same skipped-rule list, so - * the wire-to-camelCase mapping and the "absent means `null`" rule live in one place. - */ + static fromJSON(data: AgentActionEvaluationErrorJSON): AgentActionEvaluationError { + return new AgentActionEvaluationError(data.rule_id, data.message); + } +} + +/** Shared by the decision record and the slim status view; an absent list means `null`, not `undefined`. */ function toEvaluationErrors( data: AgentActionEvaluationErrorJSON[] | null | undefined, ): AgentActionEvaluationError[] | null { - return data?.map(error => ({ ruleId: error.rule_id, message: error.message })) ?? null; + return data?.map(error => AgentActionEvaluationError.fromJSON(error)) ?? null; } /** @@ -50,14 +55,20 @@ function toEvaluationErrors( * * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ -export type AgentActionApproval = { - /** The organization role key routed to review this action, or `null` when the subject is the reviewer. */ - role: string | null; - /** The URL a human visits to review the action. Derived at serialization time from the instance's current accounts host, so it is not stable across an accounts-domain change. */ - url: string; - /** The Unix timestamp (in milliseconds) when the approval window closes. */ - expiresAt: number; -}; +export class AgentActionApproval { + constructor( + /** The organization role key routed to review this action, or `null` when the subject is the reviewer. */ + readonly role: string | null, + /** The URL a human visits to review the action. Derived at serialization time from the instance's current accounts host, so it is not stable across an accounts-domain change. */ + readonly url: string, + /** The Unix timestamp (in milliseconds) when the approval window closes. */ + readonly expiresAt: number, + ) {} + + static fromJSON(data: AgentActionApprovalJSON): AgentActionApproval { + return new AgentActionApproval(data.role, data.url, data.expires_at); + } +} /** * Who answered a pending Agent Action and when. The answer itself is carried by @@ -65,14 +76,20 @@ export type AgentActionApproval = { * * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ -export type AgentActionResolution = { - /** The ID of the user who resolved the action. */ - resolvedByUserId: string; - /** The Unix timestamp (in milliseconds) when the action was resolved. */ - resolvedAt: number; - /** The reviewer's comment, visible to the application but never to the agent. */ - resolutionComment: string | null; -}; +export class AgentActionResolution { + constructor( + /** The ID of the user who resolved the action. */ + readonly resolvedByUserId: string, + /** The Unix timestamp (in milliseconds) when the action was resolved. */ + readonly resolvedAt: number, + /** The reviewer's comment, visible to the application but never to the agent. */ + readonly resolutionComment: string | null, + ) {} + + static fromJSON(data: AgentActionResolutionJSON): AgentActionResolution { + return new AgentActionResolution(data.resolved_by_user_id, data.resolved_at, data.resolution_comment); + } +} /** * The Backend `AgentActionDecision` object is the immutable record of what the policy @@ -188,16 +205,10 @@ export class AgentAction { data.operation, data.parameters, data.description, - (data.parameters_display ?? []).map(entry => ({ key: entry.key, label: entry.label, value: entry.value })), + data.parameters_display, data.idempotency_key, - data.approval ? { role: data.approval.role, url: data.approval.url, expiresAt: data.approval.expires_at } : null, - data.resolution - ? { - resolvedByUserId: data.resolution.resolved_by_user_id, - resolvedAt: data.resolution.resolved_at, - resolutionComment: data.resolution.resolution_comment, - } - : null, + data.approval && AgentActionApproval.fromJSON(data.approval), + data.resolution && AgentActionResolution.fromJSON(data.resolution), AgentActionDecision.fromJSON(data.decision), data.created_at, data.updated_at, @@ -219,7 +230,7 @@ export class AgentActionStatus { readonly actionId: string, /** Where the action stands. Loop while this is `pending`; act when it is anything else. */ readonly status: AgentActionStatusValue, - /** What the policy engine decided. */ + /** What the policy engine decided. Never branch control flow on this — branch on `status` above. */ readonly effect: AgentActionEffect, /** The deny rule's stated reason, or the engine-generated cause of a fail-closed downgrade. */ readonly reason: string | null, diff --git a/packages/backend/src/api/resources/__tests__/AgentAction.test.ts b/packages/backend/src/api/resources/__tests__/AgentAction.test.ts index aa2cdcf9b87..7dd9707bb56 100644 --- a/packages/backend/src/api/resources/__tests__/AgentAction.test.ts +++ b/packages/backend/src/api/resources/__tests__/AgentAction.test.ts @@ -70,8 +70,7 @@ describe('AgentAction', () => { parameters: { refundAmount: 25000, refund_amount: 100, 'Customer-Id': 'cus_9x', nested: { keepMe: true } }, }); - // The spelling used at check time is the spelling the field registry and every - // policy leaf must use, so camelCasing here would silently unmatch a rule. + // camelCasing here would silently unmatch a policy leaf keyed on the check-time spelling. expect(action.parameters).toEqual({ refundAmount: 25000, refund_amount: 100, @@ -85,8 +84,7 @@ describe('AgentAction', () => { const action = AgentAction.fromJSON({ ...actionJSON, parameters_display: [ - // A snake_case key is the direction that matters: the package camelCases - // everything else, and this is the one field §3.2 forbids it on. + // snake_case is the direction that matters: §3.2 forbids the camelCasing applied everywhere else. { key: 'refund_amount', label: 'Refund amount', value: '$250.00' }, { key: 'refundAmount', label: 'Refund amount (raw)', value: 25000 }, { key: 'Customer-Id', label: 'Customer', value: 'cus_9x' }, @@ -128,9 +126,7 @@ describe('AgentAction', () => { resolvedAt: 1755658800000, resolutionComment: 'Duplicate confirmed.', }); - // api-contracts-v1 §3: the approval block survives resolution, and `expires_at` - // becomes historical rather than being cleared. `status` is what says the action - // is terminal. + // api-contracts-v1 §3: the approval block survives resolution rather than being cleared. expect(action.approval).toEqual({ role: 'org:support_manager', url: 'https://accounts.example.com/action-approval/agtact_2mK', @@ -288,8 +284,7 @@ describe('AgentActionStatus', () => { }); it('maps an absent evaluation_errors to null, not undefined', () => { - // The contract always sends the key, so this asserts the mapper's own floor: a - // caller checking `evaluationErrors === null` must not be defeated by an omission. + // The contract always sends the key; this pins the mapper's own floor of null over undefined. const { evaluation_errors: _omitted, ...withoutErrors } = statusJSON; const status = AgentActionStatus.fromJSON(withoutErrors as AgentActionStatusJSON); From 39ed2310e4b0f2383aca9dd636bf41bf23f06c1e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 25 Aug 2026 00:15:50 -0600 Subject: [PATCH 5/5] refactor(backend): drop cross-references to workspace-only specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove test comments citing `api-contracts-v1` and `§3.2`: those documents are not reachable from this repository, so the citation cannot be followed by anyone reading here. The behaviours they described are already named by the test titles and asserted by the tests themselves. Remove the Deserializer note about the absent `agent_action_decision` arm. A test asserts the fall-through and fails if the arm is ever added, which makes the comment a second, non-enforcing copy of a claim already pinned in code. Remove the "Not to be confused with" sentences from `AgentAction` and `AgentTask`. They documented an alphabetical adjacency in `ObjectType` rather than anything about either class, and one of them altered the published JSDoc of an existing resource to point at a type no release contains yet. Merge the two changesets into one entry covering both packages, and name the `AgentActionStatus` / `AgentActionStatusValue` split so consumers know which export carries the union. --- .changeset/olive-pugs-smile.md | 5 ----- .changeset/quiet-toys-invite.md | 5 ++++- packages/backend/src/api/resources/AgentAction.ts | 3 --- packages/backend/src/api/resources/AgentTask.ts | 2 -- packages/backend/src/api/resources/Deserializer.ts | 1 - .../backend/src/api/resources/__tests__/AgentAction.test.ts | 3 --- 6 files changed, 4 insertions(+), 15 deletions(-) delete mode 100644 .changeset/olive-pugs-smile.md diff --git a/.changeset/olive-pugs-smile.md b/.changeset/olive-pugs-smile.md deleted file mode 100644 index cca4b09d0d2..00000000000 --- a/.changeset/olive-pugs-smile.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/shared': minor ---- - -Add the experimental `AgentActionStatus` type, the lifecycle status of an agent action awaiting a policy decision. It is declared here so that `@clerk/backend` and the approval review surface share one definition rather than each carrying their own. diff --git a/.changeset/quiet-toys-invite.md b/.changeset/quiet-toys-invite.md index d39b83f9f70..da5f26180a5 100644 --- a/.changeset/quiet-toys-invite.md +++ b/.changeset/quiet-toys-invite.md @@ -1,7 +1,10 @@ --- '@clerk/backend': minor +'@clerk/shared': minor --- -Add experimental `AgentAction`, `AgentActionDecision`, and `AgentActionStatus` resource types, describing an agent operation that was checked against a policy, what the policy decided, and how a human resolved it. These are exported as types only; the `clerk.policy` methods that return them ship separately. +Add experimental `AgentAction`, `AgentActionDecision`, and `AgentActionStatus` resource types to `@clerk/backend`, describing an agent operation that was checked against a policy, what the policy decided, and how a human resolved it. These are exported as types only; the `clerk.policy` methods that return them ship separately. + +The lifecycle status an agent action can hold is exported from `@clerk/shared` as `AgentActionStatus`, and re-exported from `@clerk/backend` as `AgentActionStatusValue` to distinguish it from the resource of the same name. It is declared once so that `@clerk/backend` and the approval review surface share a single definition. Branch control flow on `AgentAction.status`, never on `decision.effect` — an action that could not be routed to a reviewer is created with a `denied` status while its decision still reads `require_approval`. diff --git a/packages/backend/src/api/resources/AgentAction.ts b/packages/backend/src/api/resources/AgentAction.ts index dc5973746f9..01f5b0a0422 100644 --- a/packages/backend/src/api/resources/AgentAction.ts +++ b/packages/backend/src/api/resources/AgentAction.ts @@ -144,9 +144,6 @@ export class AgentActionDecision { * was asked, how they answered. Every `check()` creates one, including operations the * policy immediately allows. * - * Not to be confused with {@link AgentTask}, an unrelated session-creation affordance that - * only sorts next to this object alphabetically. - * * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ export class AgentAction { diff --git a/packages/backend/src/api/resources/AgentTask.ts b/packages/backend/src/api/resources/AgentTask.ts index 8f5f76392ab..95017849655 100644 --- a/packages/backend/src/api/resources/AgentTask.ts +++ b/packages/backend/src/api/resources/AgentTask.ts @@ -2,8 +2,6 @@ import type { AgentTaskJSON } from './JSON'; /** * The Backend `AgentTask` object represents an Agent Task resource. Agent Tasks are used for testing purposes and allow creating sessions for users without requiring full authentication flows. - * - * Not to be confused with {@link AgentAction}, an unrelated record of an agent operation awaiting a policy decision. */ export class AgentTask { constructor( diff --git a/packages/backend/src/api/resources/Deserializer.ts b/packages/backend/src/api/resources/Deserializer.ts index 9b7795a178f..c783b6ef84e 100644 --- a/packages/backend/src/api/resources/Deserializer.ts +++ b/packages/backend/src/api/resources/Deserializer.ts @@ -148,7 +148,6 @@ function jsonToObject(item: any): any { return ActorToken.fromJSON(item); case ObjectType.AgentAction: return AgentAction.fromJSON(item); - // No AgentActionDecision arm: a decision only ever arrives embedded in an agent_action. case ObjectType.AgentActionStatus: return AgentActionStatus.fromJSON(item); case ObjectType.AllowlistIdentifier: diff --git a/packages/backend/src/api/resources/__tests__/AgentAction.test.ts b/packages/backend/src/api/resources/__tests__/AgentAction.test.ts index 7dd9707bb56..7bd6aa34e9c 100644 --- a/packages/backend/src/api/resources/__tests__/AgentAction.test.ts +++ b/packages/backend/src/api/resources/__tests__/AgentAction.test.ts @@ -84,7 +84,6 @@ describe('AgentAction', () => { const action = AgentAction.fromJSON({ ...actionJSON, parameters_display: [ - // snake_case is the direction that matters: §3.2 forbids the camelCasing applied everywhere else. { key: 'refund_amount', label: 'Refund amount', value: '$250.00' }, { key: 'refundAmount', label: 'Refund amount (raw)', value: 25000 }, { key: 'Customer-Id', label: 'Customer', value: 'cus_9x' }, @@ -126,7 +125,6 @@ describe('AgentAction', () => { resolvedAt: 1755658800000, resolutionComment: 'Duplicate confirmed.', }); - // api-contracts-v1 §3: the approval block survives resolution rather than being cleared. expect(action.approval).toEqual({ role: 'org:support_manager', url: 'https://accounts.example.com/action-approval/agtact_2mK', @@ -143,7 +141,6 @@ describe('AgentAction', () => { decision: { ...decisionJSON, effect: 'require_approval', reason: 'missing_subject_for_approval' }, }); - // The five-part wire signature from api-contracts-v1 §3. expect(action.status).toBe('denied'); expect(action.approval).toBeNull(); expect(action.decision.effect).toBe('require_approval');