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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/32924-requires-confirmation-describe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/spec': patch
---

fix(spec): the `tool.requiresConfirmation` rejection no longer says REST `/actions` never reads the `ai.exposed` opt-in

Clause-②: no
Metadata-protocol: none

The prescription an author meets when a tool still declares `requiresConfirmation` stated that REST `/actions` is not `ai.exposed`-gated. That route now reads the opt-in for one pair: an AI service account (an agent acting for itself, with no `onBehalfOf`) starting a `type: 'flow'` action whose flow declares `runAs: 'system'` is refused `403 PERMISSION_DENIED` unless the action declares `ai.exposed: true`. The sentence now names that pair and says no confirmation is asked there, and keeps the rest of the bound: for a person's call REST `/actions` stays outside the `ai.requiresConfirmation` gate. The `os migrate meta` guidance for the retired key (the `tool-requires-confirmation-retired` semantic entry's replacement and acceptance text) carries the same correction, and no longer tells an author that an API-key agent on REST `/actions` is theirs to gate without saying an AI service account acting for itself is already refused a gated action there. Text only; what the schema accepts and refuses is unchanged.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ One of the largest consumers of the flag;
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `requireManageMetadata`, `requireReadCapability` |
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `refuseUngrantedActivationWrite`, `refuseUngrantedActivationAuthoring` |
| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), at the permission-set clone door, at the import of a remote table as an object (the REST import route and the MCP `import_object` tool), and at the refresh of a federated datasource's cached catalog. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `walledCatalogWriteVerdict`, `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite`, `refuseWalledFlowDefinitionWrite`, `registerExternalDatasourceRoutes`, `federationService` |
| 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes, and the elevated self-triggered start and the start-record check at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). And starting, through the same doors, a flow on a record key the caller's own scope does not deliver, which every other caller is refused with the action door's `404 RECORD_NOT_FOUND` before the run exists (and `400 VALIDATION_FAILED` for a key that is not one id, or for an elevated start that names no object). The three doors ask one check, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `mayReadRunState`, `refuseUngrantedFlowWrite`, `isRunStarterOrRunStateReader`, `refuseUngrantedRunLifecycleWrite`, `flowStartRefusal`, `refuseUnreadableFlowStartRecords` |
| 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes, and the elevated self-triggered start, the AI-service-account elevated start and the start-record check at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A) — and starting, through the same doors, a flow declared `runAs: 'system'` as an AI service account (an agent acting for itself, with no `onBehalfOf`) where the action's `ai.exposed: true` opt-in is not in hand, which that account is refused `403 PERMISSION_DENIED` (ADR-0151 Non-goals, ruling record 6812617393, letter C; a trigger route and a declared endpoint carry no opt-in, so there it is always refused, and the refusal names the action door that admits it). And starting, through the same doors, a flow on a record key the caller's own scope does not deliver, which every other caller is refused with the action door's `404 RECORD_NOT_FOUND` before the run exists (and `400 VALIDATION_FAILED` for a key that is not one id, or for an elevated start that names no object). The three doors ask one check, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `mayReadRunState`, `refuseUngrantedFlowWrite`, `isRunStarterOrRunStateReader`, `refuseUngrantedRunLifecycleWrite`, `flowStartRefusal`, `refuseUnreadableFlowStartRecords` |
| 57b | Connector credential door's capability and posture gates pass | runtime | Get: listing, setting and clearing the environment's stored connector credentials (`/automation/connectors/credential-refs`) without `manage_metadata`, and under the `group` / `isolated` postures, where the door refuses every other caller `403 PERMISSION_DENIED` naming the posture, the platform operator included, because the store is environment-level. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more about the value: no answer of this door carries it | `refuseUngrantedConnectorCredentialAccess`, `refuseWalledConnectorCredentialAccess` |
| 57c | Connector connection door's capability gate passes | runtime | Get: reading, beginning, completing (the OAuth callback) and revoking an organization's connection of an `oauth2` `authorization_code` connector instance (`/automation/connectors/:name/connection`, `/automation/connectors/oauth/callback`, ADR-0143 D5) without `manage_metadata`. No posture refuses it: a connection belongs to its organization. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more: no answer of this door carries a token, and the callback's `state` is still bound to the user and organization that began the connect | `refuseUngrantedConnectorConnectionAccess` |
| 58 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `assertDeploymentAuthority` |
Expand Down
17 changes: 12 additions & 5 deletions packages/spec/src/ai/tool.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ const TOOL_RETIRED_KEY_GUIDANCE: Record<string, string> = {
// [ADR-0151 D3 rule 3] The first bound carries the account rule: an AI
// service account never confirms for itself, so the gate refuses it at every
// door, REST included; the REST carve-out holds for every other caller (a
// person, or an agent with `onBehalfOf`).
// person, or an agent with `onBehalfOf`). The one place REST reads the
// `ai.exposed` opt-in (ADR-0151 Non-goals, `flowStartRefusal` rule 2 in
// `packages/runtime/src/flow-start-admission.ts`) asks for the opt-in alone,
// never a confirmation, so the bound names that pair and keeps REST outside
// this gate for every other caller.
//
// The ids stay in this comment and out of the string: `check:doc-authoring`
// reds on an internal tracker id inside a customer-facing prescription.
Expand All @@ -98,10 +102,13 @@ const TOOL_RETIRED_KEY_GUIDANCE: Record<string, string> = {
'action and the exact member to set so the caller confirms and retries. A GATE, not ' +
'a queue: nothing is parked, nothing is held for an operator to find later, and a ' +
'refused call did not run — no record was read and none was written. Two BOUNDS to ' +
'read before you rely on it. (1) The enforced set is the doors that enforce the ' +
'author\'s `ai.exposed` opt-in — today the action door reached from the MCP ' +
'`run_action` tool. REST `/actions` is NOT `ai.exposed`-gated and sits OUTSIDE this ' +
'gate for a person\'s call, so a human in front of that route is still yours to ' +
'read before you rely on it. (1) The enforced set is the doors that ask every action ' +
'for the author\'s `ai.exposed` opt-in — today the action door reached from the MCP ' +
'`run_action` tool. REST `/actions` reads `ai.exposed` in one case only (an AI ' +
'service account starting a `type: \'flow\'` action whose flow declares ' +
'`runAs: \'system\'` needs the opt-in there), and that check asks for the opt-in ' +
'alone, not a confirmation; for a person\'s call REST `/actions` sits OUTSIDE this ' +
'gate, so a human in front of that route is still yours to ' +
'arrange. An AI service account (an agent acting for itself, not on a person\'s ' +
'behalf) does not confirm an action for itself: it is refused at every action door, ' +
'REST `/actions` included, whatever its request carries, and a person runs the ' +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ export const entry: SemanticMigration = {
+ 'member to set. A gate, not a queue: nothing is parked, and a refused call did not '
+ 'run — no record was read and none was written. ⚠ Two bounds: the enforced set is the '
+ 'doors that enforce the author\'s `ai.exposed` opt-in, today the action door reached '
+ 'from the MCP `run_action` tool, while REST `/actions` is not `ai.exposed`-gated and '
+ 'sits outside the gate; and `confirm: true` is an unverifiable caller claim, so the '
+ 'gate makes forgetting loud without proving a human approved',
+ 'from the MCP `run_action` tool, while REST `/actions` reads `ai.exposed` only of an AI '
+ 'service account starting a `runAs: \'system\'` flow action (asking for the opt-in, '
+ 'not a confirmation) and sits outside the gate for a person\'s call or one an agent '
+ 'makes on a person\'s behalf, while an AI service account acting for itself is '
+ 'refused a gated action at every action door, REST `/actions` included; and '
+ '`confirm: true` is an unverifiable caller claim, so the gate makes forgetting loud '
+ 'without proving a human approved',
reason:
'`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: '
+ 'not the LLM tool set (a tool reaches the model as name / description / parameters '
Expand Down Expand Up @@ -56,8 +60,12 @@ export const entry: SemanticMigration = {
+ 'that call is a real check you can make rather than a destructive experiment. ⚠ Two '
+ 'bounds on what it proves: the enforced set is the doors that enforce the author\'s '
+ '`ai.exposed` opt-in — today the action door reached from the MCP `run_action` tool '
+ '— while REST `/actions` is not `ai.exposed`-gated and sits outside the gate, so an '
+ 'agent holding an API key on that route is still yours to put a human in front of; '
+ '— while REST `/actions` reads `ai.exposed` only of an AI service account starting a '
+ '`runAs: \'system\'` flow action, and asks it for the opt-in, not a confirmation; the '
+ 'confirmation gate is not enforced there, so a person or an agent acting on a '
+ 'person\'s behalf on that route is still yours to put a human in '
+ 'front of (an AI service account acting for itself is refused a gated action at '
+ 'every action door); '
+ 'and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting '
+ 'loud without proving a human approved. The decision above is still the one this '
+ 'criterion asks you to make. If the operation does not need a human, delete the key '
Expand Down
Loading