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
14 changes: 14 additions & 0 deletions .changeset/32936-mcp-service-account-confirmation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/mcp': patch
'@object-ui/console': patch
---

docs(mcp): the served `SKILL.md` and the `run_action` / `resume_run` tool descriptions stop teaching an AI service account a confirmation retry it can never pass (#32936)

Clause-②: no
Metadata-protocol: none

No runtime change: the refusal already exists (`actionConfirmationRefusal`, ADR-0151 D3 rule 3), and this changes only what the texts tell an agent.

- **`renderSkillMarkdown`.** The `run_action` entry keeps `confirm: true` after the human approves, and adds: under an AI service account's own key (an agent acting for itself, for no person) the call is refused whatever it sends and nothing runs; hand the action to a person, or its author declares `ai.requiresConfirmation: false` when it is safe unattended. The `resume_run` entry names the same refusal. The Console's "Download skill" mirror carries the same bytes.
- **HTTP MCP tools.** The `run_action` and `resume_run` descriptions, and both tools' `confirm` member descriptions, say the same thing in the same terms.
10 changes: 10 additions & 0 deletions .changeset/32936-skill-service-account-confirmation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/skills': patch
---

docs(skills): the `objectstack-ai` MCP tool table teaches that an AI service account is refused a confirmation-gated action whatever it sends (#32936)

Clause-②: no
Metadata-protocol: none

The `list_actions` / `run_action` / `resume_run` row kept `confirm: true` after the human approves as the retry, and that retry is still right for a person and for a delegated agent. It now adds the rule the runtime already enforces (ADR-0151 D3 rule 3): under an AI service account's own key (an agent acting for itself, for no person) the call is refused `428 ACTION_CONFIRMATION_REQUIRED` whatever it sends and nothing runs, so the action is handed to a person, or its author declares `ai.requiresConfirmation: false` when it is safe unattended. Funded by trimming the pitfall line that restated the tool-level `requiresConfirmation` retirement already given under `ToolSchema`.
6 changes: 3 additions & 3 deletions content/docs/ui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ const answer = await dispatchAction(kernel, {
recordId,
params: { owner: 'u_42' },
posture: 'ai', // your AI tool's door: see below
confirm: humanApproved, // only read under `posture: 'ai'`
confirm: humanApproved, // only read under `posture: 'ai'`, never for an AI service account
credentialHeaders, // optional: the caller's own credentials (e.g. `authorization`, `cookie`)
});
if (answer.status !== 200) throw answer.body.error; // the REST envelope's error
Expand All @@ -710,8 +710,8 @@ Pick the **posture** by who drives your door:

| `posture` | Rules | Use it for |
|:---|:---|:---|
| `'rest'` (default) | Exactly the REST door's. It reads `ai.exposed` once: an AI service account starting a `runAs: 'system'` flow needs the action's opt-in (the `flow` row under [Call it over REST](#call-it-over-rest)). | A door a person drives, or your own server code. |
| `'ai'` | The REST door's, plus the three the MCP `run_action` tool adds: a `sys_*` action needs its own `ai.exposed`, every action needs `ai.exposed: true`, and an action with `ai.requiresConfirmation` is refused **428** `ACTION_CONFIRMATION_REQUIRED` until the call carries `confirm: true`. A script action with no handler binding is also refused. A client-side or off-origin `api` action is refused **400** as under `'rest'`, with `run_action`'s sentence (`cannot be invoked via MCP`) instead of the REST prescription, so the agent is not handed the action's `target`. | **A host's in-product AI tool** — an agent picks the action, so it takes the AI door's rules. |
| `'rest'` (default) | Exactly the REST door's. It reads `ai.exposed` once: an AI service account starting a `runAs: 'system'` flow needs the action's opt-in (the `flow` row under [Call it over REST](#call-it-over-rest)). It asks for a confirmation once: under an AI service account's own key (an agent acting for itself, for no person) an action declaring `ai.requiresConfirmation: true` is refused **428** `ACTION_CONFIRMATION_REQUIRED` whatever the call sends and nothing runs: hand the action to a person, or its author declares `ai.requiresConfirmation: false` when it is safe unattended. The same holds under `'ai'`. | A door a person drives, or your own server code. |
| `'ai'` | The REST door's, plus the three the MCP `run_action` tool adds: a `sys_*` action needs its own `ai.exposed`, every action needs `ai.exposed: true`, and an action with `ai.requiresConfirmation` is refused **428** `ACTION_CONFIRMATION_REQUIRED` until the call carries `confirm: true` (an AI service account's call, whatever it carries: see `'rest'`). A script action with no handler binding is also refused. A client-side or off-origin `api` action is refused **400** as under `'rest'`, with `run_action`'s sentence (`cannot be invoked via MCP`) instead of the REST prescription, so the agent is not handed the action's `target`. | **A host's in-product AI tool** — an agent picks the action, so it takes the AI door's rules. |

`posture` is `'rest'`, `'ai'` or absent (`'rest'`); any other value is
refused **400** `VALIDATION_FAILED` before anything runs, so a misspelt
Expand Down
17 changes: 13 additions & 4 deletions packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2991,6 +2991,11 @@ export function registerActionTools(
'Supply recordId for actions that operate on a specific record, and params for any declared inputs. ' +
'An action the author gated (list_actions reports requiresConfirmation) is REFUSED unless you also ' +
'send confirm: true — ask the human first, then retry; nothing runs on a refused call. ' +
// [ADR-0151 D3 rule 3] `actionConfirmationRefusal` (runtime) never reads
// the member for an AI service account, so no retry is taught to one.
'Under an AI service account\'s own key (an agent acting for itself, for no person) the call is ' +
'refused whatever it sends and nothing runs: hand the action to a person, or its author declares ' +
'ai.requiresConfirmation: false when it is safe unattended. ' +
// [#32757] The read order `flowFailureRelaySentence` applies, taught
// to the reader as well, as the console reads it.
'A flow action that fails answers error.code FLOW_FAILED with a message naming the flow and node, ' +
Expand Down Expand Up @@ -3069,7 +3074,8 @@ export function registerActionTools(
'Set to true to confirm a call the app author gated with ai.requiresConfirmation '
+ '(list_actions reports requiresConfirmation for each action). Assert this only when '
+ 'the human in the loop has approved THIS call; without it a gated action is refused '
+ 'and nothing runs.',
+ 'and nothing runs. Under an AI service account\'s own key it is not read: the call is '
+ 'refused whatever it sends.',
),
},
),
Expand Down Expand Up @@ -3166,8 +3172,10 @@ export function registerActionTools(
'(author AI opt-in, your capabilities, your read access to the record). The rest of the flow runs ' +
'the app\'s business logic and can mutate data. When list_actions reports requiresConfirmation for ' +
'the action that started the run, send confirm: true (ask the human first); without it the call ' +
'is refused and the run stays paused. A run that fails answers as run_action does: tell the user ' +
'the reason in the second text block, never the flow and node chain in the error message.',
'is refused and the run stays paused. Under an AI service account\'s own key the call is refused ' +
'whatever it sends and the run stays paused: hand it to a person. A run that fails answers as ' +
'run_action does: tell the user the reason in the second text block, never the flow and node chain ' +
'in the error message.',
inputSchema: strictToolInput(
{
surface: 'this resume_run call',
Expand Down Expand Up @@ -3209,7 +3217,8 @@ export function registerActionTools(
'Set to true to confirm resuming a run of an action the app author gated with '
+ 'ai.requiresConfirmation (list_actions reports requiresConfirmation). Assert this only when '
+ 'the human in the loop has approved THIS call; without it the call is refused and the run '
+ 'stays paused.',
+ 'stays paused. Under an AI service account\'s own key it is not read: the call is refused '
+ 'whatever it sends.',
),
},
),
Expand Down
16 changes: 16 additions & 0 deletions packages/mcp/src/mcp-resume-run-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ describe('MCP resume_run — where it is registered', () => {
expect((await listTools(makeBridge(vi.fn()))).run_action.description).toContain('resume_run');
expect((await listTools(makeBridge())).run_action.description).not.toContain('resume_run');
});

it('teaches both doors that an AI service account is refused a gated action whatever it sends', async () => {
// [ADR-0151 D3 rule 3] The runtime never reads `confirm` for an agent
// acting for itself, so neither tool may leave the retry as its only lesson.
const tools = await listTools(makeBridge(vi.fn()));
for (const name of ['run_action', 'resume_run']) {
const tool = tools[name];
expect(tool.description, name).toContain('confirm: true');
expect(tool.description, name).toContain("Under an AI service account's own key");
expect(tool.description, name).toContain('refused whatever it sends');
expect(tool.inputSchema.properties.confirm.description, name).toContain(
"Under an AI service account's own key it is not read",
);
}
expect(tools.run_action.description).toContain('ai.requiresConfirmation: false when it is safe unattended');
});
});

describe('MCP resume_run — its declared shape', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/mcp/src/skill-md.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ describe('renderSkillMarkdown', () => {
// the server REFUSES without it has learned nothing it can act on.
expect(md).toContain('ACTION_CONFIRMATION_REQUIRED');
expect(md).toContain('requiresConfirmation');
// [ADR-0151 D3 rule 3] The retry is a person's or a delegated agent's; an
// AI service account is refused whatever it sends, and is told so.
expect(md).toContain("Under an AI service\n account's own key");
expect(md).toContain('refused whatever it sends and nothing runs: hand the action to a person');
expect(md).toContain("its refusal under an AI service account's own key whatever the call sends");
});

it('is generic — it does not enumerate any concrete schema', () => {
Expand Down
9 changes: 7 additions & 2 deletions packages/mcp/src/skill-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,14 +426,19 @@ create/update payload.
\`confirm: true\` on an action \`list_actions\` reports as
\`requiresConfirmation\` — the server REFUSES such a call without it
(\`ACTION_CONFIRMATION_REQUIRED\`) and nothing runs. Ask your human first;
the flag asserts an approval, it does not obtain one.
the flag asserts an approval, it does not obtain one. Under an AI service
account's own key (an agent acting for itself, for no person) the call is
refused whatever it sends and nothing runs: hand the action to a person, or
its author declares \`ai.requiresConfirmation: false\` when it is safe
unattended.
- **resume_run({ runId, values?, confirm? })** — continue a flow run that
\`run_action\` answered with \`status: "paused"\` and a \`screen\` (a form the
flow needs filled in). Send the screen's field values in \`values\`, keyed by
the names in \`screen.fields\`. The run completes, or pauses again on its next
screen, which you fill with another \`resume_run\`. Only the user whose call
started the run can resume it, under the same gates as \`run_action\`
(including \`confirm: true\` for an action flagged \`requiresConfirmation\`).
(including \`confirm: true\` for an action flagged \`requiresConfirmation\`,
and its refusal under an AI service account's own key whatever the call sends).
${renderFileToolLines(options)}${renderDatasetToolLines(options)}${options.federationTools === true && grantsAuthoringTools(options.grantedScopes) ? FEDERATION_TOOL_LINES : ''}${authoringTools ? renderAuthoringSection(authoringTools, describeObjectRegistered) : ''}${dataOnlyBlock}
## Conventions & gotchas

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
// ⭐ a member on no slate is refused, and the request stays pending;
// ⭐ the submitter is refused, and the request stays pending;
// ⭐ an AI service account acting for itself (ADR-0151, its own API key, on
// no slate) is refused, and the request stays pending;
// no slate) is refused, and the request stays pending: the account
// confirmation rule (ADR-0151 D3 rule 3) answers first, `428`
// `ACTION_CONFIRMATION_REQUIRED` whatever the call sends, `confirm: true`
// included — an account does not confirm an action for itself, so the
// action is handed to a person;
// ⭐ the approver is refused `428` until the call carries `confirm: true`;
// ⭐ confirmed, the approver's call decides: the request is approved and
// the decision is recorded once, as the approver's.
Expand Down
7 changes: 3 additions & 4 deletions skills/objectstack-ai/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ permissions and row-level security:
| `validate_expression` | build-accurate CEL check before a formula is authored into metadata |
| `create_record`, `update_record`, `delete_record` | writes; `delete_record` is refused with `ACTION_CONFIRMATION_REQUIRED` (428) unless it sends `confirm: true` — ask the human first, then retry |
| `delete_records` | up to 200 records of one object per call (fewer under a smaller `OS_DATA_MAX_PAGE_SIZE` or `OS_DATA_QUERY_ROW_BUDGET`), by `where` or `recordIds`, not both; a call without `confirm: true` is the preview (match count, sample ids) and deletes nothing — show it to the human, and send `confirm: true` only once they approve; over OAuth it needs `data:read` and `data:write` |
| `list_actions`, `run_action`, `resume_run` | your AI-exposed business Actions — the same `ai.exposed` opt-in as above; registered only when the bridge implements `listActions` and `runAction` (the stdio bridge does not, so it serves none), `resume_run` only when it also implements `resumeRun`. An action flagged `ai.requiresConfirmation` (`list_actions` reports it) is refused with the same 428 unless the call sends `confirm: true`, and nothing runs — ask the human first, then retry; `resume_run` submits a paused screen flow's form under the same gates |
| `list_actions`, `run_action`, `resume_run` | your AI-exposed business Actions — the same `ai.exposed` opt-in as above; registered only when the bridge implements `listActions` and `runAction` (the stdio bridge does not, so it serves none), `resume_run` only when it also implements `resumeRun`. An action flagged `ai.requiresConfirmation` (`list_actions` reports it) is refused with the same 428 unless the call sends `confirm: true`, and nothing runs — ask the human first, then retry. Under an AI service account's own key (an agent acting for itself, for no person) the call is refused whatever it sends and nothing runs: hand the action to a person, or its author declares `ai.requiresConfirmation: false` when it is safe unattended. `resume_run` submits a paused screen flow's form under the same gates |

---

Expand Down Expand Up @@ -318,9 +318,8 @@ there is **no top-level `temperature` / `maxTokens`** on an agent
`enableActionApproval: true` (approval queue ☁️), `ai.requiresConfirmation` on
the **action**, or `approval: 'always'` on an MCP tool binding. AI metadata
edits are already gated: they land as drafts a human must publish (ADR-0033).
⚠️ `requiresConfirmation` on the **tool** was REMOVED (ADR-0033 §2) — no
execution path read it, so it produced no pause; `ToolSchema` is strict, so
authoring it now fails the parse. There is no `requireApprovalFor` field.
⚠️ `requiresConfirmation` on the **tool** was REMOVED (ADR-0033 §2; retired
above). There is no `requireApprovalFor` field.
2. **Indexing everything.** A knowledge source without a `where` filter and
curated `contentFields` fills the index with drafts and boilerplate that
pollute retrieval. Source hygiene is the metadata's job; relevance tuning
Expand Down
9 changes: 7 additions & 2 deletions ui/apps/console/src/pages/developer/objectstack-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,19 @@ create/update payload.
\`confirm: true\` on an action \`list_actions\` reports as
\`requiresConfirmation\` — the server REFUSES such a call without it
(\`ACTION_CONFIRMATION_REQUIRED\`) and nothing runs. Ask your human first;
the flag asserts an approval, it does not obtain one.
the flag asserts an approval, it does not obtain one. Under an AI service
account's own key (an agent acting for itself, for no person) the call is
refused whatever it sends and nothing runs: hand the action to a person, or
its author declares \`ai.requiresConfirmation: false\` when it is safe
unattended.
- **resume_run({ runId, values?, confirm? })** — continue a flow run that
\`run_action\` answered with \`status: "paused"\` and a \`screen\` (a form the
flow needs filled in). Send the screen's field values in \`values\`, keyed by
the names in \`screen.fields\`. The run completes, or pauses again on its next
screen, which you fill with another \`resume_run\`. Only the user whose call
started the run can resume it, under the same gates as \`run_action\`
(including \`confirm: true\` for an action flagged \`requiresConfirmation\`).
(including \`confirm: true\` for an action flagged \`requiresConfirmation\`,
and its refusal under an AI service account's own key whatever the call sends).

## Conventions & gotchas

Expand Down
Loading