2026.10.36 — 2026-10-08
Breaking
Apply settings as change sets that pass the approval gate (538687d1)
Several setting changes and resets are applied together or not at all, with each invalid value reported by key. POST /api/v1/settings/changes applies a change set and POST /api/v1/settings/changes/preview says whether it would run, be held for approval or be denied. Listed settings carry their category, title and the changes waiting for approval. A change set that touches the approval settings or those of the installed approval provider is marked as able to weaken the gate.
Group the Administration page's tabs under Access, Installation, Governance and Support (b3d6455c)
The Administration page lists its sections in a vertical navigation, like Settings, under four headings: Access (users, roles, teams, API keys, group mappings), Installation (environments, data, plugins), Governance (masking rules, classification inbox) and Support (diagnostics). A heading with no tab is left out. The open tab stays in the address, so existing ?tab= links still open the same section; arrow keys move between tabs and Enter or Space opens one and moves focus to it.
Let plugins list notification channels and send notices to them (43986bed)
A plugin can read the channels an operator configured (id, name, kind and whether it is enabled, never configuration or secrets) through NotificationChannels, and queue a notice for one through OutboundNotices, which is bound to the plugin so every notice carries it as its source.
A notice is delivered by the alert dispatcher, with the same retries, dead state and delivery history. Slack and Teams show the headline, summary and facts with an Open in Studio button, email uses the title as the subject, PagerDuty gets one info event and a webhook receives a signed JSON body with type "notice". The link is a path inside Studio that Studio makes absolute from its public URL; with no public URL a notice has no link. A plugin may send 600 notices per hour, each at most 8 KB.
The channel delivery log lists notices with their source. Its ruleId is now null for a notice and entries have a kind and a source, which changes the API response.
Add the approval gate contract and raise the extension contract to 12 (8a511d30)
The plugin API gains
io.github.sudoitir.artemisstudio.kernel.gate, the contract of an approval gate (ADR-0179, ADR-0180):OperationGate,GatedOperationand the@Gatedmarker for operations that may need a second person's approval; theApprovalProviderinterface a plugin implements to allow, hold or deny them; the read view of held operations and their events; andCanonicalJson, the one written form a held request is bound to (sorted keys, no nulls, no floating-point numbers, NFC strings, at most 64 KB, hashed with SHA-256 over type, version and form). Nothing is gated yet.
Added
Tell a viewer whether their session verified a second factor (704512b2)
The held-operation detail now carries mfaVerified for the current viewer, so the console can warn an approver up front that a policy needing a second factor will refuse them. Only the boolean is exposed.
List the gated operation types for plugins and the console (58892555)
GatedOperationCatalogue.list() returns the type, version and execution mode of every operation the gate knows, plugins' included, ordered by type, and is exported to plugins. GET /api/v1/gate/operations serves the same list to any signed-in user. Traits are left out: they depend on a request's parameters.
Shorten a provider's hold to the maximum instead of refusing it (9e8f0fe7)
A plugin cannot read gate.max-hold, so a provider that asked for longer was refused with approval-unavailable. The engine now holds for the maximum and logs it. A hold under the 1 minute minimum is still refused.
Make a plugin's notices idempotent with a dedupe key (cb134cab)
A plugin relay keeps its cursor in its own database, so it cannot commit the cursor and the notice in one transaction. OutboundNotices.enqueue now takes a required dedupe key (at most 200 characters). Queueing a key the plugin already used is a silent no-op, enforced by a partial unique index on (source, dedupe_key), and does not count toward the hourly cap.
Give each plugin an OperationGate for its own operation types (ae452c78)
A plugin could register a gated operation but not run one, because the engine was never exported. Each plugin now gets an OperationGate bound to its id, through the same scoped-bean mechanism as the held operations. It refuses, with an IllegalArgumentException, any operation whose type does not start with <pluginId>:, so a plugin can gate only its own work.
Show the range a setting accepts (dbd00204)
The settings API now carries each setting's bounds, min and max in its kind's own syntax, and the settings page says the range under a bounded setting and gives a whole-number setting's input its limits, so an operator sees what is allowed before the server refuses a value.
Name the API token a held request was made with (0cb4af1f)
A held request now keeps the name of the API token its requester used, and its page says "API token <name>" under how they signed in, so an approver can tell a deploy pipeline's token from someone's personal one.
Export the step-up prompt, the user stream and the remaining approval types to plugins (0b4364cc)
Plugins can now import StepUpPrompt, to ask an approver for a fresh sign-in under their own decision controls, useUserStream with its StreamStatus, the inbox's InboxItem and InboxSeverity types, and ClientTarget for client.actions contributions. The plugin guide shows an approval provider contributing to the approval.decision slot, and how a held mutation settles in a plugin's UI.
Hold plugin lifecycle, license, installer and trust changes for approval (55388afb)
A held upload activation keeps only the jar's sha256, and the upload is kept past its day while a request to activate it is open. Installers, trusted keys and the trust policy always carry GATE_INTEGRITY, as does any change to the approval provider plugin.
Hold user, role, team and group mapping changes for approval (80d65874)
Each change passes the approval gate after its checks and replays as the requester once approved. The initial password of a new user is redacted and wiped when the request ends, and the second-factor check moves into the controller so a replay needs no session. Changes that could remove an approver carry GATE_INTEGRITY.
Edit settings by category, in one draft applied together or sent for approval (f2a733d7)
The Settings page lists one tab per settings category under Studio (and a plugin's settings under Plugins, named after the plugin) instead of one "Operational configuration" tab. Each category is a form with an input fitted to the setting's kind: a switch for on/off, a number field for counts, and text with its format spelled out for durations and cron schedules.
- A search above the tabs finds settings by name, key, description or category, with a "Modified only" filter. The tab list shows how many settings match in each category and hides the rest. The open tab, the search and the filter are kept in the address.
- Edits form one draft across categories. A category with unsaved edits is marked in the tab list, and leaving the page (or closing the window) with unsaved edits asks first.
- A footer shows how many changes are unsaved and where, and what applying them would do. "Apply N changes" applies them all in one step when no approval is needed. When an approval policy holds them, "Request approval…" opens a review of every change (setting, current, new) with the policy and a reason. When a policy would refuse them, the footer says why.
- A value the server rejects is reported beside its setting when the field loses focus; applying with an invalid value opens its category and focuses it.
- A modified setting shows its default and a "Reset to default" that stages the reset in the draft.
- A change waiting for approval is listed under its setting with the requester, its age and a link to the request; the requester can cancel it there.
Hold cluster and environment changes for approval (6ca860fc)
environment.create, update and delete and cluster.register, update, delete and assign-environment pass the approval gate when a provider is armed. They are access control, and a delete is destructive too. The accounts' passwords are redacted for those who read a request. A cluster is pinned by its id and registration time.
Hold transfers for approval (af95523f)
transfer.execute passes the approval gate when a provider is armed. The plan's hash is the state key, so a plan that changed is refused when it would start. A held transfer keeps its preview until the hold ends. Executing answers 202 with either the run or the held request.
Hold bulk runs for approval (695fd59c)
bulk.execute passes the approval gate when a provider is armed, as a bulk operation that is destructive when it purges or deletes. Its queues then run covered by the approved run. The plan's hash is the state key, so a plan that changed is refused when it would start. A held run keeps its preview until the hold ends. Executing answers 202 with either the run or the held request.
Hold queue and address deletes for approval (b5067ca5)
queue.delete and address.delete pass the approval gate when a provider is armed. A dry run is never held. A queue delete is estimated from the messages the scrape counts and pinned to the queue's address and routing type. The queue_lifecycle MCP tool takes approvalReason.
Hold queue purges and message actions for approval (22b80629)
queue.purge and message.move, retry, delete and expire pass the approval gate when a provider is armed. A dry run is never held. The estimate is the message count a dry run reports, without its audit row, pinned to the queue's address and routing type and, for a filter, the filter. The message_action MCP tool takes approvalReason.
Hold agent changes for approval and carry the agent's reason (8f392bec)
Every MCP tool call is an agent's for the approval gate. A tool that can be held takes an optional approvalReason, which the gate shows to whoever decides. A held operation comes back as text naming the request and where a second person approves it, never as a failure to retry; a denied, unavailable or reasonless one is a tool error with its reason.
Let a controller declare the held response (2606e26e)
Add HeldResponse for endpoints that run a gated operation, a cluster label for approvers' sentences, the shared approvalReason note for MCP tools, and a gate that runs actions as they are for feature module tests.
Gate creating a token and revoking another user's token (512d9cd5)
A new token is completed by its requester, who submits the same request again after approval and then receives the secret. Revoking one's own token is not gated.
Hold a key rotation for approval (a1c26492)
Apply settings as change sets that pass the approval gate (538687d1)
Several setting changes and resets are applied together or not at all, with each invalid value reported by key. POST /api/v1/settings/changes applies a change set and POST /api/v1/settings/changes/preview says whether it would run, be held for approval or be denied. Listed settings carry their category, title and the changes waiting for approval. A change set that touches the approval settings or those of the installed approval provider is marked as able to weaken the gate.
Let other modules list the open held operations of a type (2963c1bf)
Show approval requests in the UI and let approvers decide them (4789ab5b)
Every operation held for approval now has its own page at /approvals/<id>, which the "Sent for approval" toast and approval notices link to. It shows what will happen (the changed values as a Current and New table, with secrets shown as hidden, and the estimated effect, stated as unavailable when Studio could not estimate it), where it acts, who asked, how they signed in, their reason, the policy, a live countdown to expiry, and a timeline of everything that happened to the request. A warning says when the requester no longer holds the permission the operation needs.
Anyone who may decide a waiting request approves or rejects it there, after a confirmation that repeats the summary and effect. A rejection needs a reason, which the requester reads. The vote is bound to the request as shown, so a request that changed meanwhile is shown again instead of being decided blind, and a sign-in older than five minutes is confirmed in place and the vote sent again. An installed approval provider can add its own controls above Studio's through the new
approval.decisionslot.The requester can cancel a waiting request from its page or from the new "My requests" section of the Account page. Approval requests (/approvals, linked from the inbox) lists those waiting for your decision and your own. While the deployment's break-glass setting is on, a banner on every page says that approval checks are bypassed and audited.
For plugin authors, the SDK exports the
approval.decisionslot's props and the held-operation types (HeldOperationDetail,HeldOperationSummary,HeldDisplayRow,HeldEvent,HeldState).Hold gated operations for a second person's approval (102e93d2)
When a plugin that declares itself the approval provider is installed, every gated operation asks it first: it runs (allowed), is refused (denied, 403 operation-denied), or is held for approval (202 with X-Studio-Held-Operation and Location /api/v1/held-operations/<id>). A provider that is installed but not running, slow (gate.decide-timeout, 3 s) or failing answers 503 approval-unavailable, and nothing runs. A policy may ask for a reason (422 approval-reason-required; send it URL-encoded in X-Studio-Approval-Reason). With no provider installed nothing changes.
Held requests are kept by Studio, whichever provider holds them, bound to their exact parameters, requester and policy by seals whose key lives outside the database, and guarded by database triggers. Another person decides in a browser session with a fresh sign-in, echoing the request they were shown: never the requester, an API token or an assistant, never an account linked to the requester by email or external identity or created after the request, and not after the requester changed anyone else's access. An approved request runs exactly once as its requester, after its seals, the provider, the operation's version, the requester's account or token, the target's state and the provider's run check are checked again; anything else ends it refused. A request whose result is secret is completed by its requester. Requests expire by the database clock, end as outcome unknown when the instance running them stops, and are cancelled when the provider is removed. Everyone involved is told in their inbox.
New endpoints: GET /api/v1/held-operations, GET /api/v1/held-operations/{id}, POST /api/v1/held-operations/{id}/decision and /cancel, GET /api/v1/gate/status. New settings: gate.decide-timeout, gate.max-hold, gate.run-window, gate.max-open-per-requester and gate.run-lease. Held operations are kept 90 days after they end (Data page).
Add a notifications bell and an inbox page (1a35657e)
The header has a bell with your unread count, updated live on every page and every Studio replica without a reload; it shows "99+" past ninety-nine. It opens the latest ten notices, each with its severity, when it arrived and whether it is unread, with "Mark all read" and the way to the inbox. Opening a notice marks it read and follows its link inside Studio. A new notice is announced politely to screen readers.
The new Inbox page (/inbox) lists every notice still kept, newest first, with All and Unread views kept in the address, "Load more", "Mark all read" and a Dismiss on each notice.
Both run on a per-user event stream, one connection per tab, which refetches after a reconnect so no notice posted meanwhile is missed; while it is down the count is asked for every minute instead.
Show an operation held for approval as sent, not as a failure (e9da01bd)
When an operation is held for a second person's approval, Studio now says "Sent for approval": the operation's summary waits for a second person, with a "View request" link to its request page. It is announced politely and never shown as an error, wherever the operation started: a toast, or a dialog that shows its outcome in place.
An approval policy's refusals read in their own words and say that nothing was changed: a denied operation gives the policy's reason, an unavailable approval provider says to try again later, and a missing reason asks for one. Views on screen refresh when an operation is held, so a resource can show that a change waits for approval.
Plugin authors:
request()now throwsOperationHeldError(exported from the SDK, withHeldOperationandheldOperationsKey) when the server answers 202 withX-Studio-Held-Operation, so a mutation's success path never runs for an operation that has not run. Route a mutation's error through the newnotify.settle(error, failure), which shows a held operation withnotify.heldand anything else withnotify.failed.Group the Administration page's tabs under Access, Installation, Governance and Support (b3d6455c)
The Administration page lists its sections in a vertical navigation, like Settings, under four headings: Access (users, roles, teams, API keys, group mappings), Installation (environments, data, plugins), Governance (masking rules, classification inbox) and Support (diagnostics). A heading with no tab is left out. The open tab stays in the address, so existing ?tab= links still open the same section; arrow keys move between tabs and Enter or Space opens one and moves focus to it.
Let plugins list notification channels and send notices to them (43986bed)
A plugin can read the channels an operator configured (id, name, kind and whether it is enabled, never configuration or secrets) through NotificationChannels, and queue a notice for one through OutboundNotices, which is bound to the plugin so every notice carries it as its source.
A notice is delivered by the alert dispatcher, with the same retries, dead state and delivery history. Slack and Teams show the headline, summary and facts with an Open in Studio button, email uses the title as the subject, PagerDuty gets one info event and a webhook receives a signed JSON body with type "notice". The link is a path inside Studio that Studio makes absolute from its public URL; with no public URL a notice has no link. A plugin may send 600 notices per hour, each at most 8 KB.
The channel delivery log lists notices with their source. Its ruleId is now null for a notice and entries have a kind and a source, which changes the API response.
Add an in-app inbox with a plugin API to post notices (4b3fede8)
Each user has an inbox of notices posted by Studio features and plugins, at /api/v1/inbox (list, unread count, mark read, dismiss; own notices only). Plugins post through a scoped Inbox, so the source is always the plugin's id. Notices link only to paths inside Studio, are size-limited, de-duplicate on a key, and expire: 90 days by default on the Data page, 30 days after being read.
Add a per-user event stream at /api/v1/me/stream (9580c0f6)
Every user can open up to five streams that tell the console, with no data attached, to refetch their inbox or held operations. A signal is published through the bus, so it reaches the user's streams on every replica, and all streams resync when the bus comes back.
Arm the approval gate from the installed provider and find its operations (70a44eee)
plugin_install records whether the installed version is the approval provider, with a partial index, so arming is one read of the statuses meant to be active. Activating a second provider is refused. Disabling or uninstalling a plugin publishes PluginStatusChanged inside the transaction that changes it. ApprovalProviderRegistry and GatedOperationRegistry find the provider and the operations of the plugins attached to this replica.
Let a plugin declare itself the approval provider (0ff3bb7a)
A plugin.json may carry approvalProvider with the approver permission, which the plugin must declare itself. The validator, the JSON schema, the plugin template notes and the plugin guide describe it.
Record who changed whose access in an access change log (bf428dac)
Every access change (users, roles, role permissions, teams and their members, patterns and shares, group mappings, the default role, user grants, cluster grant cleanup) now writes a row naming the signed-in user who made it and, when one user's access changed, whose. Rows are kept 45 days and are managed under the data lifecycle. Group mappings, the default role and scope grant cleanup did not announce their changes before and now do.
Add the approval gate contract and raise the extension contract to 12 (8a511d30)
The plugin API gains
io.github.sudoitir.artemisstudio.kernel.gate, the contract of an approval gate (ADR-0179, ADR-0180):OperationGate,GatedOperationand the@Gatedmarker for operations that may need a second person's approval; theApprovalProviderinterface a plugin implements to allow, hold or deny them; the read view of held operations and their events; andCanonicalJson, the one written form a held request is bound to (sorted keys, no nulls, no floating-point numbers, NFC strings, at most 64 KB, hashed with SHA-256 over type, version and form). Nothing is gated yet.
Changed
- Merge origin/main into feat/approval-gate (0b93c1c6)
Fixed
Start module slices that run without the approval engine (5b6af917)
Let a tab whose stream was taken by a newer one wait until it is used again (a894be45)
A user with more open tabs than their stream limit had tabs closing each other's live updates in a loop. The closed stream is now told why, and its tab polls until it is focused again, then reconnects.
Name the cluster of a team pattern request instead of showing its id (5d56e45b)
List My requests first, and only while approvals are on (09323fcc)
My requests sat at the bottom of a long Account page, below the MCP connection, where a requester coming back for a decision had to scroll for it. It now follows Identity. Without an approval provider nothing is ever held, so the section is left out; earlier requests stay on the Approvals page's Mine tab.
Leave out "Requested by" from the user's own requests (f05b6fa7)
Every row on the Mine tab was asked by the signed-in user, so the column only repeated their name.
Name a request's cluster once on its page (cd0aceb4)
A request on a cluster showed the cluster twice: among the operation's own rows and in the facts beneath them. The facts keep it; an operation row that changes the cluster still shows.
Give the break-glass banner room above the breadcrumb (d98e5c39)
The banner sat flush against a cluster page's breadcrumb and was inset further than the page beneath it.
Show the break-glass banner without pushing the page down (19ac1fb7)
The banner arrived once the gate status answered, after the page had painted, and pushed every page down by its height. The shell now asks for the gate status beside the session and paints once both answered, so the banner is in the first paint.
Label the Users table's two-step column in full when narrow (f4744542)
Its header was cut to "Two-step verificati…"; when the column is too narrow for the full name it now reads "Two-step", with the full name kept for the Columns menu and assistive technology.
Keep Settings open for a numeric or repeated search (ace08028)
The router reads each address value as JSON, so a Settings link with a search such as ?q=123, or with q given twice, crashed the page ("q.trim is not a function"). The page now reads the search through the same parser as its route: a number is the text typed, and anything else is no search.
Stop cutting off the Users table's action labels (3f193318)
With Administration's narrower content beside its grouped navigation, the Users table cut "Access check" and "Reset two-step verification" off mid-word. The actions column is now as wide as its longest button, and the reset button reads "Reset" under the Two-step verification heading; its accessible name still says what it resets.
Word a refused value for its field and set its key apart (a95ae3c5)
A value the server refused showed the server's message as written for API callers, "gate.max-hold must be between 1m and 365d", under a field already labelled Longest hold; it now reads "Must be between 1m and 365d.". The setting's key under "Default" read as if it were the default value; it is now set in monospace, apart from the state.
Close the dialog that sent an operation for approval (9cf143e4)
When an approval policy held an operation, its toast said it was sent for approval but the dialog that sent it stayed open and armed, inviting a second submit: purging a queue, deleting messages, revoking a key, creating a role or user, a group mapping, a team or a team's member, pattern or share. Each now closes, or its form resets, as on success.
Say what follows on a request page, not its state twice (5a67c55d)
Every outcome notice on a request's page repeated the state the header badge already showed ("Refused when run" twice, for example); each now says what follows from it: "Nothing was changed", "It will not run", "Done". A closed request's changes are headed "What it did" or "What it asked for" rather than "What will happen", the page no longer calls every request "held" after it ended, and a requester's own waiting request no longer shows an empty Decision section that repeated who decides.
Calm a notice once the work it announced is done (ea8c89cb)
Resolving a notice (an approval request someone else decided, for example) marked it read and retitled it but kept its severity, so the other approvers' inboxes kept a warning sign on "Approved by …" items that need nothing more from them. A resolved notice is now info.
End a user's oldest stream instead of refusing a sixth (77850de5)
A user could hold at most five streams of their own (the bell's live updates) per replica, and a sixth was refused with 429. A closed tab's stream is only noticed when a write to it fails, up to a heartbeat later, so reloading the console a few times in a row left the user without live inbox and approval updates, and logged an error in the browser, until the heartbeat cleared the dead ones. A sixth stream now ends the oldest, which is most likely one nobody reads any more.
Serve the operation catalogue as an object with items (dfa3078b)
The API contract forbids a GET that returns a bare array.
Reject an Allow decision without a policy (f707c393)
GateEngine.policyLabel threw a NullPointerException on it. The record now refuses a null policy when the provider builds it, and the engine reports a provider that throws as approval-unavailable.
Set the QA stack's session lifetimes through the settings change set (9e06b504)
Hold a store's data policy as one request for approval (63c0da6b)
Saving a store's retention and quota wrote each changed value as its own settings change, so with approvals on the first was held and the rest were never sent. The policy now applies as one change set of the values that change, which an approval provider holds as one request, and its endpoint documents the held response.
Describe the settings tool's approval reason like every other held tool (05c83a01)
studio_setting now lists approvalReason with the shared note, passes it to the gate the way the other tools do, and its description no longer claims dry runs and confirmations it does not have.
End an operation's coverage when the work that continues it ends (23cf2682)
A covering ticket used to stay honoured for as long as anything still referenced it, so an operator captured inside an allowed or approved operation could run uncovered work under it later. The gate now holds the ticket for its action, the worker hand-off takes a lease on it when it captures the operator, and the first run of that operator releases the lease when it ends; the ticket is revoked when the last hold ends, and a released lease no longer carries it, so later work goes through the gate again. A bulk run's queues, run on its own thread after the request returned, are still covered.
The bulk gate test also lets the gate through before the shared clean-up, which resets settings through the gate now.
Run approvals with API tokens switched off (3c8e2b51)
The approval engine required the API token service, so Studio did not start with API tokens switched off. It now takes it when present, and a held request made with a token is refused with that reason when tokens are off.
Name the review table and the leave dialog's choices by what they do (9943c8f3)
The review dialog's table is named "Changes to request" when the draft goes for approval rather than "Changes to apply". Leaving with unsaved changes offers "Stay on this page" beside "Discard and leave" instead of a bare Cancel. The search's screen-reader status says "Showing modified settings only" when only the Modified only filter is on.
Start static table headers where their cells start, and drop the Columns control from fixed diffs (a804c1f5)
A static table's header cells are native th elements, which browsers centre, so every header sat off its left-aligned column. Headers now start where their cells start; number columns still end-align both.
A static DataTable takes columnsMenu={false} for a short fixed set where every column is needed. The settings review dialog and the approval's change table use it, so they no longer offer a Columns chooser with nothing to choose.
Name the dismiss button Keep request when cancelling a request (0b4ff357)
The Cancel request dialog showed a Cancel button next to Cancel request, so it was unclear which one kept the request. ConfirmDialog takes an optional dismissLabel, also available to plugins through the SDK, and the dialog now offers Keep request.
Name the key-encryption tab "Encryption keys" (865dfcbf)
The Studio group now has a "Security" category for session lifetimes, so the tab for the key provider and key rotation is called "Encryption keys" and the two are told apart.
Point to the settings' new places instead of "Operational configuration" (96d505f0)
The API keys panel, the MCP read-only refusal and the MCP guide named a tab that no longer exists. They now name the category: Settings → API tokens and Settings → MCP server.
Show the user name's loading as a loading state, not as text (ba0106fc)
The Account page wrote "Loading…" where the user name goes. It now holds the name's place with the shared loading state, which a screen reader announces as busy.
Security
Hold a node's URL override for approval (e493faa6)
Pointing Studio at a node by hand decides where it sends the cluster's management credentials, the same as editing the cluster's connection, which approvals already held. The override now passes the approval gate as cluster.node-override, showing the node's current and new URLs, and an approved one is refused when the node was pointed elsewhere meanwhile.