Surfaces in detail
The private runtime exposes two endpoint actions and four identity actions through two adapters. Both call Runtime.execute, which validates strict input, commits authority through the execution coordinator and consumes a newly issued dispatch ticket internally. Tickets and provider credentials never appear in responses. Connector availability is checked against both the resolved account and provider kind before a new execution can reserve capacity.
Agent interfaces
POST /v1/execute: original JSON body is anActionRequest.GET /v1/executions/{execution_id}: current AGENT credential, own tenant and principal only.GET /v1/actions: current AGENT credential; the operations the agent's own space offers, with each one's arguments and target kinds, and never a target, a rule, a limit or a placement (threat model, T1.17). No body and no query string.POST /v1/executions/{execution_id}/outcome: an external executor's report on the grant it holds for that execution. The same authentication as every other agent route; the report secret in the body names which grant is being settled and is no substitute for the credential. The act grant is not presented here and cannot be — extra fields are refused — because the report window deliberately outlives the act window. See External execution checkpoint.POST /preandPOST /post: a control point's Pre- and Post-Execution hooks, served only when the private configuration carries anarcadeblock and normally reached through the separatecreate_arcadeprocess. The same AGENT authentication and the same request discipline as the routes above, and a different body rule: the monitoring intake's, which admitsapplication/jsonandapplication/json; charset=utf-8compared case-insensitively and nothing else, each route under its own byte bound. See Arcade logic extension./mcp/: authenticated stateless Streamable HTTP, withexecute({"request": ...}),execution_status({"execution_id": ...})andlist_actions()tools. Atools/callmay leaveargumentsout; when it is sent it must be an object. Use the trailing slash.
All agent requests carry Authorization: Bearer <credential>, where the credential is the agent's API key, an opaque token an administrator issues in the console or with the CLI (Get an API key). It does not implement OAuth discovery, an authorization server or external identity federation. MCP client metadata, tool caches, session identifiers and logical runs cannot establish authority. Every MCP tool call is logged as mcp.call with the tool name, the protocol revision the request carried and the client's self-reported name and version (cut to 80 characters), identifiers that establish nothing; never the arguments, never the result. The execute tool accepts reference in place of idempotency_key and derives the key exactly as the Python client does, so a model-driven host is never asked to invent a key.
The service pins official mcp==2.3.0. Compatibility tests run the current client (protocol 2026-07-28) and the actual maintained mcp==1.30.0 client (protocol 2025-11-25) in an isolated, hash-locked environment. This is SDK compatibility evidence, not validation with a named practitioner's host application. The official releases identify the two supported release lines; the ASGI integration guide describes the transport lifespan.
Raw MCP JSON passes duplicate-key, numeric, envelope and size checks before SDK decoding. The tool argument schema is generated as a complete wrapper so references resolve in validating clients. Unsupported protocol input responses, request state and task fields cannot become human approval. Action arguments go through the same parser as HTTP, which admits only bounded scalars, and then through the same catalog normalization during resolution, which rejects anything outside the resolved action's spec before a fact is read.
Pending results contain execution/approval IDs, the frozen plan hash and a signed status continuation when runtime keys are configured. The continuation binds tenant, principal, credential, audience, IDs, plan, expiry, nonce, key ID and HMAC-SHA256/version. execution_status accepts an optional continuation; HTTP status accepts X-Execution-Continuation. A continuation only validates status metadata. It never grants approval or replaces current authentication. Unknown/retired keys, altered scope, expiry and noncanonical encodings fail closed.
There are four audiences. The fourth, execution.checkpoint_outcome, is the act grant beside a checkpoint; it is the only audience that carries an operation_id, which the other three refuse outright so the field cannot be attached to a status, approval or reconcile token to make one look like authority over an operation. The field serializes only when set, so a continuation of the other three audiences is byte-identical to one issued before the field existed. Nothing in ExecBound verifies the checkpoint audience today: it is a signed statement the executor holds, and the outcome route authenticates on the report secret instead.
HTTP uses 202 for pending or uncertain execution, 200 for terminal state, and bounded errors for invalid input (400), invalid role (403), scoped absence (404), conflicts (409), load limits (429) and unavailable dependencies (503). A terminal DENIED is an execution result, not a successful mutation. MCP returns the same state data in structured tool results, with isError for request failures.
Human interfaces
Accounts and workspace selection
Workspace switching is core: GET /api/console/v1/session/workspaces and POST /api/console/v1/session/workspace below answer on every deployment, with or without a plugin, and the session's can_switch_workspaces is true for any session signed in through an account.
Personal password or an already-bound provider subject signs in to an account. Its workspace
memberships supply local permissions; losing access to the original workspace leaves another
active membership usable. Unverified legacy addresses retain their original workspace access
but cannot gain expanded membership merely from their stored address. Unknown provider subjects
are not attached by matching email. A signed-in owner starts explicit linking with
POST /account/link/{provider} from account security, using CSRF, recent authentication,
verified email and required MFA; the callback consumes one state/provider/session-bound proof.
An account with no password, as a Google, GitHub or Microsoft signup leaves it, is offered a first one on
account security: POST /account/security/password takes csrf, password and confirm,
compares the two as bytes, and calls set_first_password under the same bar as linking. It
never replaces a password, which /settings changes with the current one; every refusal is a
sentence on the page, and the change ends the account's other sessions.
The organization's MFA requirement (everyone, administrators, or each person's choice) is set
on the same page by a workspace administrator alone (decided 8 October 2026, migration
0074_workspace_settings_admin), because it applies to every person of the workspace: an
administrator narrowed to spaces is not offered the form, POST /account/security/policy
answers anybody who is not a workspace administrator 403 before anything is written, and
account_security_change and user_security_change refuse it RECENT_AUTH_REQUIRED, the
answer an approver gets.
Beside it, the same administrator alone sees the box "Allow trusted browsers in this
workspace", ticked by default, posted to POST /account/security/trusted_browsers (csrf, and
allow=on only when ticked; any other value is 403). It calls trusted_browsers.set_allowed,
which asks for a factor verified within the step-up window (the page says to verify first,
otherwise), is refused INVALID_IDENTITY to anybody but a workspace administrator, and appends
account_security.trusted_browsers_changed with the session and the new value. With it off,
every sign-in to and every entry into that workspace asks for the factor whatever trust the
browser holds.
Legacy administrator password codes remain bootstrap-only for unlinked principals.
GET /api/console/v1/session/workspaces lists the current account's usable memberships. Each
item names the workspace, the kind a session there would carry, the roles it grants across the
whole workspace (a role granted in one space is not one of them) and what entering it asks
beyond this sign-in: open, factor (the switch asks for the account's code), enrol (it asks
for a factor to be set up) or directory (only the organization's own sign-in opens it). Where
the switch would carry this session's verification (below), a workspace that asks for a code is
open: the current session's own verification is read once, under its own workspace's row
security, before any other workspace is entered. It is open too where this browser's trust
cookie would satisfy the account's factor there (trusted_browsers.applies: a live trust at the
account's and factor's current versions, in a workspace that allows trust), read without writing
anything, since the switch then applies it. For a
workspace other than the current one whose entry is open, the item also carries how many
approvals wait there for this account's principal, read with the pending approval queue's own
predicate under that workspace's row security, capped at 100, and the earliest of their deadlines
floored to the minute (waiting_state counted). A count is a number, never a record, and it is
withheld, with no count read, wherever entering asks for more proof (withheld); past the 25th
workspace nothing more is counted (not_counted). Each item also carries last_entered_at, when
this account last entered that workspace (null for one it never has), from the account's own
membership record; the menu orders by it after the current workspace. The list is read again with
the session, never on a timer of its own and never in a hidden tab, and reading it writes no event.
POST /api/console/v1/session/workspace requires the account cookie, CSRF and the displayed
X-ExecBound-Workspace session generation. Selection recomputes roles and MFA requirements,
invalidates the old workspace session and resets the Space view. The new session carries the old
one's second-factor verification when, inside the switch, that verification is of the account's
current, enabled factor and is under thirty minutes old, the step-up window; the carried
verification keeps its own time, so the thirty minutes still count from the code, and nothing
else of the old workspace is carried. A carry is recorded twice: for the account, with the
workspace and session it came from and the ones it opened, readable only by the account itself;
and as account_security.assurance_carried in the workspace it opened, which names no other
workspace. Older verification, a factor since replaced or removed, and a workspace that asks for
a factor of an account with none still ask, as before, unless the browser holds a trust the
destination honours: the switch then applies it as GET /mfa would (workspaces.trusted_entry),
writing the new session's assurance with method trusted and the trust's original verification
time and rotating its token, so the answer is the section rather than /mfa; a step-up there
still asks for a code, because a trusted verification is never fresh. Required-directory entry
fails closed and directs the person to ordinary organization sign-in. Setup credentials and
customer OIDC alone cannot acquire account switching or global account-management authority.
The body may name the section the person is on, one of a closed set of console page roots
(Home, Approvals, Activity, the Live board, Policies, Agent connections, Users, Groups & roles,
API keys, Approval rules, Spaces, Monitoring, Getting started, Workspace settings, Help and My
account), never a record, a query or another address; anything else is 400 INVALID_BODY. The
answer is that section when the new session may open it, else where that session starts
(Approvals for an approver who may decide, Home for everyone else), or /mfa when the
destination asks for a factor, in which case the signed five-minute return cookie carries the
section through the code. The switch writes nothing but a section into that cookie; a section root
is also one of the console pages a sign-in link may name (GET /login?review=, below).
The workspace selector is separate from the Space selector. A switch clears cached data and
live streams. Every other tab of the browser replaces its page with one that names the workspace
it showed and where the browser is now, and links to its section's root in the new workspace,
never to a record; after a sign-in again to the same workspace it offers to reload its own page
instead. Tabs tell one another over a broadcast channel; a message is shape-checked, shown as
text and grants nothing, and a tab acts on another tab's sign-in only once a read of
GET /api/console/v1/session answers with that session's generation in its header. Requests and
stream reconnects carry their original generation so stale controls cannot act in the newly
selected workspace. Personal
session cookies last up to the credential's absolute expiry, while the server enforces the
twelve-hour idle limit on every request. Ordinary reads renew server state without rewriting
those cookies, preventing a delayed old-workspace response from overwriting a new selection.
Approval notifications link to /approvals/{approval} (and its own classic suffix), or to the
workspace-scoped address a loaded plugin registered for approval links. Signing in from a
workspace-scoped approval address opens that workspace when the account is an eligible member of
it; once signed in, a different workspace still requires explicit selection. Without such an
address, a sign-in opens the eligible workspace the account entered most recently (by signing in
there or switching to it), else its oldest membership. Opening a notification never switches the
workspace of a session that already exists or grants review permission by itself. After a
switch that asks for a code, the second step names the workspace it opens ("Verify to open
{workspace}"); a code that
was just used, the one still on screen after it opened another session, is answered as such and
never counts toward the five-attempt lock. Account
security history and global sign-out belong to the account owner. Local administrators manage
local membership/session access and cannot reset the shared password/MFA or turn a local
privacy-request resolution into global account deletion. Teammate invitation
acceptance is its own flow, below; these account primitives do not enable it.
Existing sign-in and review routes
The console is a single-page application served at the console's own paths. GET /, /activity, /agents, /agents/{agent_id}, /approvals, /approvals/{approval_id}, /approvers, /access, /ask, /ask/{turn_id}, /board, /credentials, /monitor, /monitor/agents/{agent_id}, /people, /people/{person_id}, /policy, /policy/{bundle_id}, /policy/{bundle_id}/rules, /report, /settings, /help, /spaces, /tenant and /welcome each serve the application's one document to a signed-in person. Those are exactly the addresses core's application has (CONSOLE_ROUTES): the server enumerates them rather than answering from a catch-all, tests/test_console_serving.py holds the list equal to core's generated route tree, and an address outside it answers 404 rather than a shell that would render "not found" with a 200. A plugin's console edition adds addresses of its own, which the server answers only where that plugin is loaded (How the console is served, Editions); the hosted service's are documented with the hosted service.
A person signs in through their existing account, with an email and password, a configured provider or their organisation's directory. GET /login offers the password form posting to POST /session/password and configured provider buttons, and links "Forgot your password or lost access?" to the deployment's help page where its service block names one (below). It has no review credential field. Below every way to sign in it may carry one plain link a loaded plugin registered, on the page itself and on every refusal that lands back on it; core adds none. A loaded plugin may also register a recovery link (Registry.recovery_link, held to the same rules): with one, the page shows it in place of "Forgot your password or lost access?" and keeps the deployment's help page for a lost second factor, as "Lost your authenticator app and recovery codes? Get account help". The hosted service's reset by email is documented with the hosted service. The explicit installation entry point /login?setup=1 retains the setup credential exchange at POST /session; it grants no review permission. Reviewing actions describes assignments and notification links. A provider's flow is POST /login/{provider} and GET /login/{provider}/callback; a name no block configured answers 404, so nothing a request sends chooses a flow. Where a loaded plugin registers Registry.provider_signup, a page that started the same flow for a signup — never /login itself — sets a provider_signup intent cookie beside the ordinary one, and the callback reads it once and dispatches to that point instead of the ordinary sign-in once the flow completes: a mismatched or unregistered intent is refused rather than quietly falling through to a sign-in, and a flow with no such cookie reaches the ordinary sign-in exactly as before. A successful callback answers with a page of this application's own, "Signed in." with a refresh and a link to the landing page, rather than a 303: the provider's redirect back is a cross-site navigation, a request that follows a redirect in that chain is sent without the SameSite=Strict session cookie the callback just set, so a 303 would land a signed-in person back on /login. The session cookie stays Strict; what changes is that the next request starts on this site. GET /set-password and POST /set-password redeem a one-time code an administrator or owner tooling minted.
Signing in returns a person to their place. GET /login?review=<address> keeps the address as a navigation hint: a signed, HttpOnly, Lax, five-minute cookie under its own HMAC domain, refused when duplicated and deleted once a session is issued without a pending factor. What it may name is one closed grammar, login_return.canonical, which every writer and reader of the hint uses: one of core's own console route shapes with lowercase-UUID parameters (the workspace-scoped approval address only where a loaded plugin's approval links mount it; a plugin's own console pages are never returnable), with no second origin, backslash, fragment, encoded slash, whitespace or control character, at most 512 characters in and 256 out. On five pages a few query keys are kept, each with a UUID or a value from a closed list (Home's open and range; Activity's open, agent, target, state, since, action and outcome; Approvals' view; Monitoring's view, state, open and focus; Getting started's step), sorted; every other key is dropped, and a key given twice refuses the whole address. None of the kept keys acts on load: each only chooses what the page shows, and a key is admitted only after its page is checked to act on a click alone. An address the grammar cannot use (a stale tab, a route this server does not have, a hand-typed address) still reaches the ordinary sign-in page, sets no hint and deletes any hint the browser already holds, so a garbled link lands on the landing page. A console page opened without a session answers 303 to /login?review= with its path and query, or, when only the second factor is pending, signs the same hint before it answers /mfa. The hint grants nothing: every page it returns to re-authorises its reads. Every one of them is public, carries its own CSRF cookie compared with the field, applies the exact Origin check on the post, and counts against the peer limit and the per-address login limit; a wrong address and a wrong password answer 401 with the same sentence, and the private reason reaches record_failure and the structured log alone.
A workspace administrator registers one OpenID Connect provider of the tenant's own, on tenant settings, and it is a row rather than a block in the configuration file: the issuer, the client id, the subject claim (sub, or oid for Entra ID) and a client secret kept as AES-256-GCM ciphertext under the deployment's sign_in.directory key ring. Without that ring there is nowhere to keep a secret, so no provider can be registered, the card says so and every route below answers 404. Discovery is fetched and validated at registration and the three endpoints are stored on the row, so a document that changes later cannot move a live flow; an Entra issuer must name one directory, and common, organizations and consumers are refused. The deployment's own Microsoft button, which a sign_in.microsoft block configures, is not this: it is a personal sign-in method like Google, uses the multi-tenant organizations authority, signs in only an account that linked it, and never opens a workspace that requires sign-in through its own directory. A provider is draft until the administrator completes a real sign-in through it, which links their own account and marks it verified; enabled makes it available and disabled revokes every directory session under it. Only a workspace administrator configures it (decided 8 October 2026, migration 0073_directory_workspace_admin), because it signs people in to the whole tenant rather than to a space: an administrator narrowed to spaces (no workspace-wide access.manage, and a membership of any space) still reads GET /api/console/v1/tenant/sign-in, which their people's invitations stand on, and the card shows them the provider with a note in place of Register a provider, Test sign-in, Enable and Disable; POST /api/console/v1/tenant/sign-in and POST .../{provider_id}/state answer them 403 before discovery is fetched or anything is written, as the access page does, and directory_register and directory_set_state refuse them INVALID_ACTOR again. One redirect URI serves every tenant: <origin>/login/directory/callback. The flow is POST /login/directory/{provider_id} and that callback, with GET /login/directory/{provider_id}/link for a signed-in person binding their own account. A workspace administrator may start that self-link through a draft, which is the test sign-in that verifies it; anybody else, an approver or an administrator narrowed to spaces, only through an enabled provider, from My account, and a draft or verified provider answers them 404 as the public start does. directory_store.link_self refuses the same again before directory_link_self runs, and since 0073 the function refuses it itself (INVALID_PROVIDER); before then it accepted an approver's sign-in through a draft as the evidence that verifies it. The callback ends on the same page of this application's own as a provider's, "Signed in." or, for a self-link, "Linked.", and for the same reason. The transient __Host-eb_dirflow cookie is the one SameSite=Lax cookie these routes set and carries a MAC under the same ring, because the callback is a cross-site redirect back and the session cookie is not sent with it: which invitation is being redeemed and who is linking come from that cookie, so it has to be one only this server could have written. GET /invite and POST /invite redeem a one-time invitation code, ebv1.<tenant_id>.<invitation_id>.<secret>, stored only as its SHA-256, single use, one hour to fourteen days, revoked by five wrong secrets and by the next code, and never in a URL; the sign-in that follows binds the subject to the invited person in the same function call that consumes it. /login offers the tenant's button to a browser carrying __Host-eb_directory, a hint cookie a successful sign-in through that provider set, so the form never says which addresses exist or where they work.
A directory session is rechecked against its provider whenever its renewal is due. A renewal is due once seven and a half minutes have passed since the session was last extended, whether or not the row can still move (a session at its two-day end is rechecked on every request), and at that point the refresh token is redeemed at the stored token endpoint, outside every database transaction, whenever the last verification is more than five minutes old. invalid_grant, or an ID token whose subject or tid changed, revokes the credential and every session under it on that request, which is what Entra ID and Okta both answer once an account is disabled, deleted or has its sessions revoked. Every other error word, invalid_client included, is unavailability: the session stands while the last verification is within fifteen minutes and is refused after that, and nothing is revoked, so a directory outage that resolves lets people carry on rather than forcing a tenant-wide sign-in. In a workspace that requires its directory, a session whose last verification is older than those fifteen minutes is refused DIRECTORY_REQUIRED by the session check itself, which is where a person back after more than fifteen minutes away would land; the pages, the console shell and the console API answer that one refusal with the recheck first (directory_store.load_rechecked), through the same claim, so the session loads when the directory confirms the account, is ended when it refuses, and is refused when it cannot answer. A person keeps a directory-required session for the twelve idle hours like anyone else. A password or credential session reads one indexed row and reaches no directory at all.
Whatever proves who the person is, a verified sign-in mints an internal credential row whose secret is discarded and issue_session runs unchanged, so sessions, CSRF, cookies, renewal, revocation and the recheck are the same for all three and the role comes from the person's principals row either way. The OAuth flow cookie is not SameSite=Strict: __Host-eb_signin, HttpOnly, five minutes, carrying the state, the nonce and the PKCE verifier, is Lax, because the provider's redirect back is a cross-site top-level navigation and a Strict cookie would not be sent with it. It is deleted on first use, so a replayed callback has nothing to match. The pages' form-action names the authorization host of each configured provider, because that directive governs the redirect a form submission follows; each host is pinned in that provider's module and is in the policy only because a block named the provider.
A set-password code is ebp1.<tenant_id>.<reset_id>.<secret>, stored only as its SHA-256, single use, expiring between an hour and fourteen days, revoked by five wrong secrets and by the minting of the next one. It never appears in a URL: what an administrator hands over is the address to open and the code to type, as a directory invitation code is. Redeeming one revokes every session and credential the person held, because a code somebody could have read is a recovery and a recovery must not leave the previous holder signed in.
Three things stay server-rendered, because they run before a session exists or because a script cannot be relied on: /login (with the password form, a provider's two routes and /set-password beside it), the HTML error pages, and /approvals/{approval_id}/classic, the classic approval page every approval notification links from its footer and the console's own approval route links from its error boundary. They take a small stylesheet of their own, /assets/app.css, on the console's tokens and IBM Plex, and none of them loads a script.
The addresses that moved redirect permanently, so a bookmark, a browser's history and a link in an old message all still arrive: /admin/people to /people, /admin/people/{id} to /people/{id}, /admin/credentials to /credentials, /admin/approvers to /approvers, /admin/settings to /tenant, /policy/rules to /policy, /monitor/{view} to /monitor?view={view}, and /next and /{route}, where the console was served while it was being built, to the route itself. Each target is a constant path with only its path parameters substituted and the original query string carried; nothing in the request selects a host or a scheme, and a redirect reads no session and touches no row.
Open /login and use normal account sign-in. An approval notification returns a signed-out person to the exact request after sign-in, using a signed five-minute navigation cookie (Lax, HttpOnly, cleared after sign-in). It grants no authority and accepts no arbitrary return URL. Sign-in issues an opaque session and CSRF cookie; only hashes are retained in PostgreSQL. Sessions expire after 12 idle hours and never outlive the original credential; a page load made 7.5 minutes after the session was last extended renews it for another 12 hours, never past two days from sign-in, so a reviewer who keeps reading stays signed in, and the directory recheck keeps its 7.5-minute cadence. A sign-in still waiting for its second factor lasts 15 minutes. The factor prompt offers "Trust this browser for 30 days": a code, recovery code or passkey verified with it ticked sets the eb_trust cookie (__Host-eb_trust over HTTPS; HttpOnly, SameSite=Lax so a provider's cross-site callback still carries it, thirty days), and a later sign-in on that browser that owes the factor goes straight on from GET /mfa, with the trust's original verification time, so a step-up still asks for a code. The approval page names which clock set the expiry, the tenant's approval deadline or the plan's context validity, and its decision form stays valid for the session and the approval rather than for two minutes. At most five sessions are live per credential: a sixth sign-in on one credential (a setup credential exchanged again without signing out) ends the oldest, with a human_session.revoked event whose reason is SESSION_CAPACITY, rather than being refused. Logout revokes the retained session immediately. Credential expiry changes, revocation, role changes and disabled principals/tenants invalidate existing sessions.
Both human account kinds see Approvals within explicitly assigned space/canonical-target/action scope; HUMAN_APPROVER also has scoped Activity access. A permitted person opens one approval's detail (/approvals/{approval_id} in the console, and /approvals/{approval_id}/classic the server-rendered page behind it, the classic address the console's notifications and error boundary fall back to for a phone whose console does not load or that blocks scripts) to review the frozen facts with their source, writers and observation time, the obligations, the applicable limits (each labelled with its scope, Group <name>, Account <id>, <fact> = <value>, Principal or Tenant, and its action list, rendered as inert text; the Activity review details show the same list), the decisions so far and the time left, then approve or deny with an optional bounded comment that lands in the audit event. Every page has one h1, labelled controls, captioned tables with scoped headers, a skip link as the first focusable element and a one-column layout at phone width; tests/test_browser_console.py runs axe-core over every console route in both schemes at 1280 and 390 px, and tests/test_browser_accessibility.py audits the pages that stay server-rendered. A browser request (one that accepts text/html) that fails answers an HTML error page with the same status as the JSON error and only generic, status-keyed copy (session ended, wrong role, not found, already decided or changed, emergency stop, too many attempts, unavailable) with links to sign in and home; API and intake clients keep the JSON body. An ADMIN manages policy bundles from /policy (list, create a draft from JSON) and /policy/{bundle_id} (inspect, validate, preview, activate with confirmation, retire) through the same audited store functions as the CLI. An ADMIN also issues and revokes agent and approver credentials from /credentials (a new secret is rendered exactly once, on the issuing response, and never stored or shown again) and manages approver assignments from /approvers; both pages write only through the SECURITY DEFINER database functions of migration 0021_admin_functions, which recheck the tenant and acting ADMIN inside the database. Credential administration still refuses ADMIN principals and credentials, while migration 0045_review_permissions permits review assignments on existing ADMIN accounts, so the gateway holds no table write privilege and cannot create or change administrators. An ADMIN also reviews read-only platform collection: the Connections page shows the collection connection's pinned origin, team scope, probed capabilities with their classification and probe time, health, audit cursor and last run beside the reporting sources, and Monitoring's inventory view lists discovered stories with the coverage class the reconciliation assigned and credential metadata, stating that action options, credential values and addresses are never retained and that an ordinary action's effect is not an audit record. Collected rows on the Activity page carry the collected evidence label with their operation name and actor reference. Monitoring's agents view and /monitor/agents/{agent_id} show what each agent a directory lists can reach, through which path, and whether anything governs it: the list carries a tenant line that states its denominator in words and names the two things Graph does not serve, and the detail page shows every path with the certainty that path can establish, the enforcement mode it can support and the moment its proof lapses, beside the grants, findings and observations attributed by object identifier (Scout). The activity list is the operations console: filters by state (including the uncertain queue of committed and indeterminate operations), action, agent and time window, cursor pagination of 50 records, dispatch evidence on uncertain rows and the ADMIN reconciliation form; query strings are refused everywhere except the keys that list accepts. Both roles have a tenant home at / (an approver's pending approvals first; for an ADMIN the figures, the decisions chart, what needs them and the recent decisions) whose rows open the record drawer and which changes nothing. ADMIN sees tenant Activity and can request evidence-based reconciliation with a reason. AGENT cannot sign in or use these methods. Approvers cannot reconcile; administrators cannot substitute for assigned human approval.
The hosted service adds its own signup and onboarding pages (/signup*, /onboard); they are documented with the hosted service.
Three routes and one asset serve teammate invitation acceptance: GET /join is static and side-effect free, GET /assets/invite.js moves the link's fragment (#ebt1.<tenant>.<invitation>.<secret>) into the form and out of the address bar and history before anything else can read it, and a browser with scripts blocked is shown a paste box instead. The page is /join, not /invite: directory_pages.py already serves GET/POST /invite for the customer-directory one-time invitation code, mounted ahead of this feature's own router, and Starlette hands a request to the first route that matches path and method, so a second /invite route here would never be reached. POST /join is the exchange: it reads only what the link is for through inspect_invitation, changing nothing but a wrong guess's failure count, answering retry (503) rather than invalid for a fault on the server's side (INVITATION_UNAVAILABLE), the same recoverable page /join/accept and /join/confirm already show, and shows the workspace, the inviter and the proposed roles in plain words ("Viewer in all Spaces", "Approver in Payments"), then one of a password form (an address nobody signs in with yet), "This address already has an ExecBound account. Sign in, then open the link again." (an existing or legacy address, signed out), an Accept button for the right account already signed in, or mismatch for a different one. POST /join/accept (new address) takes a password and confirmation, and on success mints a session with issue_session/session_response; a fault signing that new account in, after its acceptance already committed (sign_in_invited_account, guarded the same way issue_session already is), answers the same "You have joined. Sign in." page a retried acceptance of an already-accepted link answers too, rather than a bare 500. POST /join/confirm (signed in) re-authenticates the account cookie and the session inside the accepting transaction before calling accept_invitation, behind the ordinary guards.mutation CSRF/Origin checks, and shows "You have joined <workspace>. Choose it from the workspace menu." for a fresh acceptance, an already-committed retry, or ALREADY_MEMBER alike, because all three share the one true fact that the account is a member; a session still waiting for its own MFA is treated as signed out and sent to /mfa, never shown the invalid or mismatch page. FORBIDDEN (the inviter lost authority) and INVALID_TENANT both render the same invalid result a forged link does, so a stranger never learns why. Every page is Cache-Control: no-store under the site's default Referrer-Policy: same-origin, and the exact Origin check applies to both mutation routes (surfaces/invite_pages.py, invitations.py). See Threat model T4.35 to T4.39.
The console API adds /api/console/v1/teammate-invitations (list, create, resend, revoke). Every route wraps execbound.invitations exactly as the access pages wrap access_control: the service's own fenced transaction re-checks access.manage on every call, so no route here adds a role gate of its own, and create, resend and revoke all step up through the same recent-MFA check guards.mutation already applies to /access/, /people/ and /credentials, because a grant here can name administrator exactly as those can. create schedules one best-effort delivery attempt as a background task after its transaction commits, using the same lease the scheduled drain would use next, so the response never waits on mail and nothing runs where this deployment has no relay configured; the list route filters to the last thirty days by created_at alone, which already covers every still-open row since resend never extends expires_at. An invitation is an email, so the session carries mail, whether this deployment has a relay at all, and the console offers to invite by email only where it does: without one, the People page says that inviting needs a relay and points at Add user, which sends nothing, and the API still records an invitation it is asked for, for a relay added later to deliver. The jobs service runs the drain, invitations-run, every two minutes only where the runtime configuration has a mail block (schedule.jobs_for). On the People page, an ADMIN with access.manage and a relay sees an Invite teammate button opening a one-address, one-role, one-Space-scope dialog (InviteTeammate.tsx; an invitation carries exactly one grant, so this is its own small form rather than the multi-grant GrantPicker) and a pending-invitations table (TeammateInvitations.tsx) showing the address, role, delivery state (Sending, Sent, Failed) and expiry, with Resend and Revoke on each row. The card, titled "Invitations", lists every PENDING invitation the list route answers, expired ones included: an expired row reads Expired with its expiry time and one action, Invite again, which creates the invitation again with the same address, grants and reviewer group (the create path already revokes the expired row with reason: EXPIRED and retries in the same transaction). An accepted one is a person on the People list and a revoked one was ended on purpose, so neither is listed. With nothing left the card reads "No open invitations.". For the Approver role the dialog also offers a Reviewer group (reviewer_group_id on the create body, and reviewer_group_id and reviewer_group_name on each listed row): it lists the workspace's groups with their role grants, disables a group the server would refuse (one granting more than review, or more than this invitation), and under the chosen group lists the active rules that name it and the eligible-reviewer preview the rule editor uses. The server checks the group at creation (INVALID_BODY naming reviewer_group_id) and again at acceptance, where a changed group is skipped and recorded rather than applied; the token is never shown to the console, which only ever learns that a send happened.
Approval forms carry a signed continuation bound to the current human credential, session/CSRF nonce, exact execution/approval and frozen plan. Current scope, policy, context, capacity and approval expiry are checked again in the coordinator. Session validity is checked inside the same fenced transaction before and after writes. Replaying a completed decision cannot transition a non-pending approval. Reconciliation rechecks the session after provider lookup and before cancellation, and before settlement commits.
Cookies use HttpOnly, SameSite=Strict, path / and no Domain. HTTPS configuration adds Secure, __Host- cookie names and HSTS. Every form mutation requires the exact configured Origin and a matching CSRF token. Host, duplicate cookies, alternate authority headers and unsupported form fields are rejected. Cleartext origins are permitted only for a literal loopback IP.
The response uses Referrer-Policy: same-origin. This preserves the Origin on legitimate same-origin HTML form submissions while withholding cross-origin referrers; the exact Origin check remains enforced. Human list reads revalidate the persisted session before and after their queries under the tenant control fence, including expiry or logout between initial authentication and the read.
An externally executed operation is labelled as such on both pages, with the precondition class and, once the row is settled, the evidence tier as inert text, so an approver sees what the tenant will and will not be able to prove. Every label is paired with a state or with a read, and one asymmetry is deliberate: the class is shown only for a record no grant has been issued for, a pending approval or a refusal, and is worded as what this deployment declares for the binding, because that is the only source a page has. A grant carries the class in force when admission issued it and a redeployment moves the declaration, so a record that has a grant is shown no class at all and is pointed at its own execution.checkpoint_granted audit events, which retain the class the grant actually carried. The canonical claim a settlement on the executor's bare word left held is read from the row rather than inferred from its state, and the row says what releases it. The activity list's uncertain queue lists unreported grants and offers an ADMIN the connector-free settlement control, which takes the executor's own signed record rather than a cancellation. The same control is offered for one settled row, the _correctable one — a grant a control point closed on its own unchecked word that the call failed — where a verified non-execution corrects the charge instead of ending the uncertainty, and the copy beside the control says which of the two it is doing. Any other settled row is offered none, and the approver's view of a labelled row mentions no settlement control in any state, because can_reconcile requires ADMIN.
Both pages add a Requested arguments section listing the frozen intent's name: value pairs, omitted when the action declares none, so a reviewer approves the exact option the agent asked for. When a frozen plan names an action or a context contract the running catalog does not contain, the row renders the escaped label unknown action <name> or unknown contract <version> instead of failing the whole listing.
Jinja templates enable automatic escaping and strict undefined values. Target names, provenance, arguments and other records are rendered as text. Content Security Policy prohibits scripts, framing, foreign styles and foreign form targets on these pages. Jinja's API documentation describes explicit autoescaping.
The pages that stay and the console share one design: a graphite base with colour reserved for decisions, signal cyan as the one accent, and IBM Plex Sans with IBM Plex Mono for identifiers, self-hosted under font-src 'self'. The token blocks of assets/app.css are copies of console/public/tokens.css, held equal to it value for value by tests/test_page_tokens.py, and no colour literal appears outside them; the pages take the console build's own font files, so a deployment with no build falls back to the system stack and still reads. The checkpoint mark is inline SVG in the header and the footer and never inside main, with its bar in the accent; one badge class, status, has modifiers that name states (allow, deny, pending) rather than colours, so a template never chooses a colour. tests/test_console_tokens.py holds every documented pairing to its WCAG 2.2 AA floor in both schemes and tests/test_browser_accessibility.py measures what renders on the pages that stay. The mark is also a favicon, served at /favicon.ico and /favicon.svg — where a browser asks for it whatever a document says — and linked from the console's document and from the pages that stay; the bytes are the console build's own single copy.
A person chooses the scheme for their browser: System, Light or Dark, on /settings, where System is the default and is prefers-color-scheme as before. The choice is a cookie holding one of three words, and the gateway reads it when it serves the console document and renders data-eb-scheme on the root element, which two blocks of console/public/tokens.css take over the system setting. It has to be the server and it has to be a cookie: the console carries no inline script and paints a plain frame from the tokens before any script runs, so a scheme the application applied would be one frame of the other scheme on every visit. The cookie is a preference and never a credential — it is read by one function, it decides only which palette paints, and no row records it, so the same person on another machine gets that machine's setting. Any value but the two forced words is System. The live board stays full ink in every appearance.
Two settings pages. /settings is personal, for both human kinds: who the person is (principal prefix, role, tenant name), the credential they signed in with (prefix, issued and expiry times, and what to do when it is lost), and every active session signed in as them with its device label, sign-in and expiry times and a "This device" mark; one confirmed form, Sign out other devices, revokes every other session of that principal inside the tenant fence through the runtime's existing revoked_at update and appends human_session.revoked with reason OTHER_DEVICES per row, keeping the current session; it asks for no password, because the session is at most two days old and the form carries the CSRF token and a confirmation, which the threat model records as the accepted re-authentication. Where the deployment has account security, a Trusted browsers card lists each browser the account trusted at sign-in for thirty days, newest first, with its device label, when it was added, when it was last used and when it expires, marking "This browser" by the browser id in this browser's trust cookie (the cookie's secret and its hash never reach the page); Remove on a row and Remove all, each behind a confirmation, end trusts with no step-up, because removing only tightens, and clear this browser's cookie when its own trust goes. The card says when the workspace does not allow trusted browsers. "Sign out other devices", a password change and a factor change move the account's version, which ends every trust, so the card is then empty. The device label is chosen at sign-in from the User-Agent by a fixed mapping of browser and system families ("Chrome on Windows", "Safari on iPhone", "Other") and stored in human_sessions.label (migration 0035_session_label); the header itself is never stored or rendered. A Directory identity card shows the person's own account at the tenant's directory, when one is linked: the provider, the subject, the name and username the directory reported (its text, rendered as text), how it was linked (by the person, by an administrator, or by an invitation they redeemed), when, and the last sign-in through it, with a note when signing in through that provider is not enabled at the moment. With no link and an enabled provider it offers Link my identity, a navigation to the self-link route below; with neither it says there is nothing to link. Who removes a link depends on the viewer's effective kind, which the session carries: an approver is told that unlinking is an administrator action, taken on the person's page; an administrator, whose link directory_unlink refuses (it takes only a principal whose kind and effective kind are both HUMAN_APPROVER), is told only that their link is not removed from the console. /tenant is for an ADMIN (403 for an approver): the enforcement mode, the approval deadline (120 to 3,600 seconds or off, with the two-clock sentence) and the emergency stop (active since, by whom, the escaped reason, the committed dispatches recorded at activation, and a link to the uncertain queue), each changed through a form with a bounded required reason and the confirmation hook that calls set_enforcement_mode, set_approval_deadline, activate_stop or release_stop with the session's identity, so the console and execbound tenant-mode, tenant-approval-deadline, tenant-stop and tenant-resume leave identical audit events; a refusal re-renders the page with the generic notice and never a value from the form. Only a workspace administrator changes the mode, the deadline, the stop or the assistant switch (decided 8 October 2026), because each applies to every space: an administrator narrowed to spaces (no workspace-wide access.manage, and a membership of any space, spaces.narrowed) reads the whole page, the active stop and the mode included, and is offered none of the four controls, a note saying who changes each standing in their place; the store functions refuse them INVALID_IDENTITY, the answer an approver gets, and the four kernel functions behind the mode, the deadline and the stop audit the refusal as execution.rejected (the assistant switch writes no event), so the console and the command line refuse alike and nothing is written; in particular they cannot release a stop a workspace administrator activated. Only a workspace administrator reads or answers the data and closure requests (decided 8 October 2026), because the queue holds every person's requests, from every space, with each requester's name and id, and a response closes any of them, a workspace closure included: a narrowed administrator is shown a note in place of the queue, which is not read for them; GET /api/console/v1/account/requests and POST /api/console/v1/account/requests/resolve answer them 403 FORBIDDEN, refused in the request's own transaction before any request is read or written, and account_change refuses the response itself since 0075_workspace_requests_admin. The two command-line reads that answer for every space, execbound observe-report and execbound rotation-readiness, refuse them INVALID_IDENTITY the same way. A person's own requests, filed and read on My account, are unchanged. A read-only block shows the tenant name and id, the active bundle and monitoring health with links. Approval readiness holds the deadline and its dialog, and states, for each action the active policy can hold, how long a held request can wait at most: GET /api/console/v1/tenant and the four tenant changes carry approval_windows, computed by execbound.approval_routing.windows for the session's current Space, or for each visible non-archived Space (at most 50, by name) under "All spaces". Per mapping, the figure is the smallest effective max_age_seconds over the kinds of fact its contract resolves, read through the bundle's own context settings as admission reads them, capped by the deadline; an action served by several mappings takes the largest, a mapping whose contract this catalog does not know gives no figure, observe-only mappings are not listed, and a custom Cedar bundle whose approval policies may hold an action marks it as not known. Every figure is an upper bound, because a request's time starts when its facts were recorded. The same card lists the configured approval destinations and the security event export as id, kind, host, classes, approver count and the messages each receives, never the URL or the secret, with the week's delivery line; a destination that receives findings alone is listed on Alert settings instead. Under Enforce, when a window is under ten minutes and no destination announces approvals (one whose messages list is empty, the audit export, does not, nor does one whose classes leave out approvals), it warns once, with a link to Approvals and a remedy the console edition supplies through its ApprovalRemedy slot; core's names the operator's notification configuration. The deadline dialog warns, as a deadline under ten minutes is typed where something announces approvals and the measured delivery hint does not apply, that a message can arrive after the request lapsed. Nothing in the card writes anything but the deadline. My account and, for an ADMIN, Workspace settings sit at the foot of the console's sidebar. Both pages pass the phone-width accessibility audit and the contrast probe in both schemes.
The People pages are ADMIN only and cover the humans of a tenant. /people lists every HUMAN_APPROVER and ADMIN principal by label (else email, else "User N" by the person's number since 0062_people_numbers), kind, state, live credentials, active sessions, last sign-in and the number of review scopes, sorted by kind then label, filtered by state (active by default) and kind; one form, New approver, creates an approver with a required label and lands on their page. /people/{principal_id} is the person: the label with an edit form, kind, prefix, state and times; their credentials with Revoke per live row and an Issue form rendering the same show-once page, through the same functions the credentials page uses (an ADMIN's rows say "owner tooling"); either human kind's review scopes with Assign and Unassign through the approvers page's functions; their active sessions with device labels and End all sessions, the administrator's counterpart of Sign out other devices, which revokes every session of that person under the fence through the runtime's existing revoked_at update and appends human_session.revoked with reason ADMIN per row (not offered for the acting administrator, whose sessions are on /settings); and Deactivate with a required reason, or Reactivate. Deactivation calls admin_deactivate_principal, which sets active = false and deactivated_at, revokes every live credential and every unrevoked session of the person in the same statement sequence and returns their identifiers, and the page appends principal.deactivated with the reason and the counts, one credential.revoked per credential and one human_session.revoked with reason DEACTIVATED per session, inside the same transaction, so the change is atomic across the rows and the chain; scopes are kept, so admin_reactivate_principal restores the person exactly, with no credential. Both functions refuse an ADMIN target and the actor, keeping the 0021 rule that the console cannot create or change administrators; nothing is ever deleted. admin_label_principal sets principals.label, which every admin page shows beside the prefix. A cross-tenant or unknown id is 404 and an agent principal is not a person; agents have their own pages, below. An administrator narrowed to spaces administers only their spaces' people (since 0072_space_principal_scope, decided 8 October 2026): /people lists, and /people/{principal_id} and every change to a person reaches, only a person seated in at least one space, every one of which is a space where the administrator's own membership is ADMIN, and whom no role from the access model reaches (a managed person, or a member of an access group); anyone else answers 404. A person seated nowhere has workspace-wide reach and is not theirs. The definer function behind each change asks the same in its own statement, and End all sessions, the runtime's own update, carries it too. A workspace administrator sees and changes everyone. New approver seats the person it creates as an approver in the space the administrator writes in, so the person a space administrator adds is always theirs. Users opens the Team & access group of the console's sidebar. Both pages pass the phone-width accessibility audit and the contrast probe in both schemes, and the export secret check.
The person page also carries History. An approver's page shows their last twenty decisions from approval_decisions (the time, the approval's prefix, the action, the target's prefix, approved or denied), which stay after the scope is unassigned, and their last twenty sign-ins. An administrator's page shows their last twenty sign-ins and their last twenty administrative events. A sign-in is a human_session.issued event of the person, shown with the session's and the credential's prefixes and, from the session row, its device label and its state now (active, signed out, expired). An administrative event is one of a fixed list of kinds whose actor is the administrator: policy bundles, credentials, approver scopes, principals, agent bindings, sessions ended for someone else (reason ADMIN or DEACTIVATED, never a person signing out their own devices) and the tenant's mode, deadline and stop. Each is shown as fixed words and one subject, an identifier from the payload parsed as a UUID and shown as its prefix with the label of the principal it names, and a mode change adds ENFORCE or OBSERVE. Only those identifiers and the mode leave the database; reasons, hashes and every other payload value are never read into the page. No route, migration or write: three bounded statements run between the page's two authority checks. The audit chain has no index by actor, so the two audit reads walk the tenant's chain backwards until they find twenty, which is bounded at today's sizes.
The Agents pages are ADMIN only and cover the tenant's AGENT principals. /agents lists them with label (else "Agent N" by the agent's number since 0058_api_key_numbers), state, live credentials, requests this calendar month and the last request, ordered by label with unlabelled agents last, cursor-paginated at 50, and filtered by state (active by default) and by whether the agent made a request this month. Above the list, "active governed this month" counts the AGENT principals that submitted at least one request in the current UTC calendar month, with that definition printed beside it; the list, the figure and the agent page read one query. One form, New agent, creates an agent with a required label. /agents/{principal_id} is the agent: the label with an edit form and its groups; its requests this month and active runs; its credentials with Revoke and Issue, through the credentials page's functions and show-once page; its reach through ExecBound, one row per action the active bundle governs (inferred) or the agent executed in the last thirty days (verified, with the evidence's expiry), with the mode reach.governed reports, the same rows Monitoring derives for a linked agent, read and never written, and, when the active bundle was written as rules, the rules it holds for that action by name and effect, each a link to that bundle's rules page, with a governed path no rule names saying so; its last twenty requests, linking to Activity filtered by the agent; its limits, every rule of the active bundle on an action its space is offered or it has already been charged under (a group rule only for a group it belongs to, a space rule only for its own space and an account rule only for an account its space is offered that action through), the principal-, tenant- and group-scoped ones each as "n of m" with the sum admission makes (confirmed impact inside the window, every unresolved row, observed rows in observe mode, and only unapproved rows for an autonomous budget), and account-, selector- and space-scoped ones listed without a count; and Deactivate with a required reason, or Reactivate, through the People page's functions and events. Every form first checks that the id is an agent of this tenant, so nothing under /agents reaches a person; a cross-tenant, unknown or non-agent id is 404, and since 0072_space_principal_scope so is an agent of a space where an administrator narrowed to spaces holds no ADMIN membership, which the definer functions refuse as well. Such an administrator may still read the agents of a space they are seated in as an approver. Agent connections sits under Agent security in the console's sidebar. Both pages pass the phone-width accessibility audit and the contrast probe in both schemes, and the export secret check.
The same list carries the agents a monitoring connection lists. A row is a bound pair, addressed by its principal, a governed-only principal, or a discovered-only agent, addressed by its agent id, with its population, source and identity class, the first sponsor's object id prefix, consequential reach with its unknown count and governed paths from Monitoring's own counts, requests this month, last activity, open findings and state (active, deactivated, disabled at the source, gone from the source); the filters add population, source, ungoverned reach and gone. /agents/{id} resolves a principal first, and a bound agent's id answers with its principal's page, whose address the console then follows. A discovered agent's page carries what the source lists (source, identity class, platform id, blueprint, sponsors by object id prefix, credential kinds, first and last seen, state at the source), its reach from Monitoring with a "Govern this action" link to Policy for an action reached only outside ExecBound, its observations and findings with the sponsor named as their owner, Bind to an unbound active agent principal, and Govern this agent, which creates the principal labelled with the display name, binds it and issues a credential in one transaction and shows the credential once in a dialog with the Claude Code MCP line and the install line for this gateway, never the secret inside them. A governed agent's page adds its binding with who bound it and when, Bind to an unbound discovered agent, or Unbind with a required reason; a bound agent's page shows the discovered facts and one reach table in which the ExecBound rows name the binding. Bindings are written only by admin_bind_agent and admin_unbind_agent (migration 0037_agent_bindings) through execbound/bindings.py, which appends agent.bound and agent.unbound in the caller's transaction; the functions keep Scout's monitor_agents.linked_principal_id in step, the runtime role can write neither, and link-agent goes through the same path.
The tenant line sits at the top of /agents: one row per monitoring connection that is active or still lists agents, with its state and last collection, counting the agents it lists, those with consequential reach, those bound, and those whose reach is not established, each out of that source's own total, with a totals row across sources and never across to the governed side; the governed side counts active agent principals, how many are bound, and the active governed this month figure with its definition. The counts use Monitoring's own definitions over the same rows, so the listed and not-established totals equal Monitoring's Agents view, and the denominator is stated in words: agents a connection could enumerate, not the agents that exist, and never an agent with no identity. With no connection, the line says so and links to Monitoring's connections page instead of showing zeros.
The capacity line opens the tenant line, above the governed side, inside the page's "Discovery coverage and agent capacity" disclosure. It reads Agents: <n>. for a workspace with no agent limit and Agents: <n> of <limit>. for one with a limit, which is the workspace's own tenants.max_agents. When the workspace is at or over its limit, the refusal's own sentence follows on the same line, This workspace has reached its agent limit. Every agent you have keeps running., so a refused creation is never the first time a workspace hears the limit exists. When any agents are asleep the line adds one more sentence naming how many and pointing at what to do: <n> agent(s) is/are asleep after ninety days without a governed request. Open an agent marked Asleep to reactivate it. The count is exactly what the cap counts, live and sleeping agent principals, so the line and the refusal can never disagree: /api/console/v1/agents reads the line's agent_limit, agents_held and agents_dormant from execbound.capacity.agent_capacity, and the line carries no plan. The core console shows no plan; the hosted service's plans are documented with the hosted service.
A self-hosted deployment meets no agent cap. Every tenant it creates has no agent limit unless its operator sets tenants.max_agents. The capacity line above is a status line for one workspace, not a presentation of tiers; the hosted service's own limits are documented with the hosted service.
Every agent row and the agent detail page tell a sleeping agent from a deactivated one, because neither reads as merely active: false. Only a plugin puts an agent to sleep, through the dormant agents_sleep, so where none is loaded no agent is ever asleep; the hosted service's dormancy is documented with the hosted service. The list's StateCell renders one of three badges per governed row: Active (green) when the principal is active, Asleep (an observational tone) when it is inactive with dormant_at set, and Deactivated (a stale tone) when it is inactive with neither marker meaning sleep. The list's default State, Active, does not show an asleep agent, since it is not active; State set to Deactivated or All does, and a Space whose agents are all asleep keeps the list's filters rather than reading as a Space with no agents. Whether a Space holds an asleep agent is the page's space_dormant, counted in the Space the list is read under whatever its filters, never the tenant line's workspace-wide agents_dormant, so a Space with no agents still reads as one while another Space's agents sleep. The detail page and its binding card carry the same distinction in prose rather than only a badge: an unbound agent that is asleep reads "This agent is asleep after ninety days without a governed request; reactivate it first," where a hand-deactivated one reads "A deactivated agent cannot be bound; reactivate it first," and the discovered-source card says the governed principal is "asleep after ninety days without a governed request" or "deactivated" beside the fact that the source still lists it. Reactivating a sleeping agent takes the same Reactivate control a deactivated one uses (admin_reactivate_principal, which clears whichever of dormant_at/deactivated_at put it to sleep); there is no separate wake control on this page. The two differ at the cap: a sleeping agent never stopped counting, so waking it is never refused, while a hand-deactivated agent freed its allowance, so reactivating it on a workspace already at its agent limit is refused PLAN_LIMIT with the same sentence a refused creation shows (409 from POST /api/console/v1/agents/{principal_id}/reactivate).
PLAN_LIMIT is registered on every page whose own _call/_PROBLEMS table has to carry whatever admin_create_principal can raise, all carrying the identical sentence so a person reads the same words for the same fact wherever they meet it: the Agents page's own _PROBLEMS table (agents_pages.py, whose New agent form genuinely can reach the cap), the Credentials page's table (admin_pages.py, whose Issue form can name new:AGENT when issuing a credential with no principal selected and genuinely can reach the cap too), and the People page's table (people_pages.py, whose own create_approver always passes kind = 'HUMAN_APPROVER' and can therefore never reach the cap in production; its PLAN_LIMIT entry exists because it shares the same generic _call error-translation helper as the other two, and a code that helper can carry needs a mapped sentence regardless of which kind this particular page's own route happens to send). The console API's console_api/directory.py REFUSALS mapping and console_api/errors.py's CODES (the JSON route a person's browser actually calls) and the console's own components/directory/errors.ts COPY table carry it too, the last one's PLAN_LIMIT_SENTENCE constant imported by AgentsList.tsx's plan-line renderer rather than retyped, so the refusal toast and the tenant line's own over-cap sentence are the same string in the same build. The three tables are kept by hand, so a code added to one needs a matching entry in the other two.
The live board
The board draws the last fifteen minutes as a board of lanes: agent principals on the left, the checkpoint in the middle, targets on the right, and every shot a row the tenant already holds. It reads the same rows as Home, under the same approver scope, in one REPEATABLE READ snapshot sharing one server-fixed instant, and it changes nothing. The console draws it at /board from GET /api/console/v1/board and its stream. The hosted service renders the same read server-side as a public showcase; that page is documented with the hosted service.
It is the console's one full-ink page, an exception to the console's shared design, because it is a display surface for a wall or a screen share.
Beneath the board, when the tenant has an active monitoring source or connection, a band says "seen by monitoring, not governed": grey shots that pass under the checkpoint, because that is what they did, with a finding marking its shot. A directory's own audit log records ExecBound's executions and the attempts it refused too, so an audit-log observation is drawn only when it succeeded and is not the one entry an execution ExecBound settled accounts for, by the same match first-consequential-action.v1 uses (Scout). It is drawn for an administrator only. An observation carries no canonical target, so it cannot be held to an approver's scope the way an execution is: showing it unfiltered would hand an approver activity outside the scope they were given, and filtering it by a target label would be a guess. The band is absent entirely when nothing is watching, because an empty band reads as "monitoring saw nothing", which is a stronger claim than "nothing is watching".
Private configuration and startup
Apply every migration to head (alembic upgrade head) with the owner connection. Among the earlier revisions, 0006_human_sessions added one forced-RLS table with SELECT/INSERT and UPDATE of revoked_at only for the runtime role; 0007 adds identity facts/action constraints, 0009 adds the read-only resource_facts table, 0010 adds the authentication_failures counters with INSERT and UPDATE of count and last_at only, 0011 adds the emergency stop columns on the tenant control row with UPDATE of those four columns, 0022 adds the read-only principal_groups and principal_group_members tables and three execution_impact columns the runtime writes at insert only, and 0025 adds the read-only executor_keys table with the settlement_tier and report_expires_at columns on executions, of which the runtime may update only the tier. Gateway history remains in the same database; each protected mock has its separate store.
Set EXECBOUND_RUNTIME_CONFIG to an ignored private JSON file. An example of its shape follows; it shows most blocks, not every one. Each sign-in provider's client_secret is required, and may come from EXECBOUND_GOOGLE_CLIENT_SECRET, EXECBOUND_GITHUB_CLIENT_SECRET or EXECBOUND_MICROSOFT_CLIENT_SECRET instead when the process starts through python -m execbound.boot, which merges it into the file it writes:
{
"origin": "http://127.0.0.1:8000",
"active_key_id": "continuation-v1",
"continuation_keys": {"continuation-v1": "<independent 32-byte key encoded as hex>"},
"connectors": [{
"tenant_id": "<tenant UUID>",
"account_id": "<trusted configured account UUID>",
"provider": "mock_crowdstrike",
"origin": "http://127.0.0.1:8001",
"dispatch_token": "<private provider dispatch token>",
"control_token": "<private provider control token>",
"evidence_keys": {"journal-v1": "<provider signing key encoded as hex>"},
"timeout_seconds": 5.0
}],
"checkpoint_providers": [{
"tenant_id": "<tenant UUID>",
"account_id": "<the Gate account, configured under no connector>",
"provider": "mock_crowdstrike",
"evidence_keys": {"journal-v1": "<the provider's own signing key, encoded as hex>"}
}],
"sample_providers": {
"mock_crowdstrike": {
"origin": "http://127.0.0.1:8001",
"sample_key": "<independent 32 to 128 byte key encoded as lower-case hex>",
"timeout_seconds": 5.0
}
},
"notifications": [{
"destination_id": "soc-webhook",
"tenant_id": "<tenant UUID>",
"kind": "webhook",
"url": "https://alerts.example.net/execbound",
"secret": "<32 to 256 ASCII characters>",
"approvers": ["<approver principal UUID>"]
}, {
"destination_id": "soc-email-jane",
"tenant_id": "<tenant UUID>",
"kind": "email",
"to": "jane@example.com",
"approvers": ["<approver principal UUID>"]
}, {
"destination_id": "soc-slack",
"tenant_id": "<tenant UUID>",
"kind": "slack",
"url": "https://hooks.slack.com/services/<the webhook's own path>",
"classes": ["approvals", "findings"]
}],
"mail": {
"host": "smtp.resend.com",
"port": 465,
"tls": "tls",
"username": "resend",
"password": "<relay password or API key>",
"from": "no-reply@notify.example.com"
},
"preconditions": [{
"provider": "mock_crowdstrike",
"provider_operation": "contain",
"precondition_class": "ENFORCED_BY_PROVIDER"
}],
"console": {"enabled": false},
"telemetry": {"enabled": false},
"service": {
"support_email": "support@example.com",
"help_url": "https://help.example.com/"
},
"sign_in": {
"google": {
"client_id": "<OAuth client id>.apps.googleusercontent.com",
"client_secret": "<OAuth client secret>",
"redirect_uri": "https://app.example.com/login/google/callback"
},
"github": {
"client_id": "<OAuth App client id>",
"client_secret": "<OAuth App client secret>",
"redirect_uri": "https://app.example.com/login/github/callback"
},
"microsoft": {
"client_id": "<application (client) id>",
"client_secret": "<client secret value>",
"redirect_uri": "https://app.example.com/login/microsoft/callback"
}
},
"arcade": {
"deadline_seconds": 4.0,
"post_bytes": 1048576,
"tools": [{
"toolkit": "endpoint_mock",
"name": "ContainDevice",
"versions": ["1.0"],
"operation_ref": "endpoint.isolate",
"target": {"kind": "provider_id", "input": "device_id"},
"incident": {"input": "incident_id"},
"inputs": ["device_id", "incident_id"],
"arguments": {},
"expect": {"destructive": true, "read_only": false}
}],
"ungoverned_toolkits": ["gmail"]
},
"key_reload_seconds": 0
}
The placeholder values are not usable credentials. Generate independent random keys and keep this file, provider configuration and owner credentials outside the protected agent's authority. Duplicated accounts, unknown fields, invalid origins and reuse of provider keys as continuation keys are rejected, as is any of the executor protocol's published test keys, or the key replay's regression fixtures are re-signed under, as a connector's evidence key, a Gate account's journal key or a sample key. Every key here is an HMAC-SHA256 key and is compared as HMAC uses it, so a key with zero bytes appended is the same key for every one of these rules. notifications (optional, up to 64) names operator destinations; kind is webhook (pending-approval notifications, the default), email (pending-approval notifications by email), slack or teams (pending-approval notifications in a channel) or security-events (the audit export, see Operations). A webhook destination is an https URL with a hostname and no credentials, query or fragment (http only for a loopback address, which the local development receiver uses), a shared secret for the HMAC signature, and an optional approvers filter of principal identifiers; an email destination names one bare address in to and carries no url or secret; a slack destination is an https://hooks.slack.com/services/... URL and a teams destination a Power Automate Workflows trigger URL (see Operations), each with no secret, because the URL is the secret; destination identifiers are unique per tenant, and the whole file is refused when one is invalid. An optional classes list says what a destination carries, approvals (the default) or findings or both, as soc-slack carries both above; a findings destination may narrow itself with rules and spaces, messages and approvers belong to approvals, and all three are described in Operations. mail (optional, required when any email destination exists) is the deployment's one SMTP relay: host, port (465 or 587), tls (tls or starttls, never plaintext), username, password, from (a bare address) and daily_limit (default 500 messages per tenant in any 24 hours); see Operations. Destinations are neither tenant data nor ADMIN-editable, like connector origins. preconditions (optional, up to 64) declares what each provider binding's vendor does with a frozen expected version, for the external execution checkpoint. Each entry names a provider, a provider_operation and a precondition_class of ENFORCED_BY_PROVIDER, ASSERTED_BY_EXECUTOR or UNAVAILABLE, and the class travels with every grant for that binding and appears wherever the checkpoint is shown. It is keyed by the binding and never by the account, because a checkpoint account has no connection configuration at all and whether a version can be checked is a property of the vendor's API rather than of one tenant's account. Two operator rules matter more than the shape. An undeclared binding promises nothing: it answers UNAVAILABLE, its grants carry no expected version, and a deployment with no preconditions block issues only UNAVAILABLE grants: the honest default, not an oversight. Declaring a class is a statement about the vendor's API that ExecBound cannot verify: nothing checks that the vendor really offers a conditional write or really exposes a version, so a class claimed for a vendor nobody checked is an overclaim on every page and every report that shows it. A binding declared twice, or one the running catalog does not bind, is refused at load like any other invalid file, and a reload that finds a changed declaration reports refused and leaves the running keys untouched, because that change needs a restart. checkpoint_providers (optional, up to 64) declares the journal key each Gate account's provider signs with, for the checkpoint's PROVIDER_VERIFIED tier. Each entry names a tenant_id, an account_id, a provider and evidence_keys (a key id to hex-encoded key, the same shape a connector's own evidence_keys take), and holds no URL and no dispatch or control token, because the gateway never calls this provider: verification only. An account named here and also under connectors or graph_connectors refuses to load, as does a duplicate entry, because a Gate account has no connector by construction and admission's own refusal of a Gate mapping whose account has a connector would otherwise disagree with this file about what kind of account it is. A journal key must be independent of every connector's evidence keys and of the continuation keys, checked at construction and again on Runtime.rotate, so the same secret can never verify two different things. Changing this block is not a key rotation: it is read once when the runtime is built and carries no rotation of its own, so a reload that finds it changed reports refused and leaves the running keys untouched exactly as a changed preconditions or arcade block does, and the change needs a restart. arcade (optional) is the tool map and deadline for the Arcade logic extension, and its presence is also what makes the deployment serve POST /pre and POST /post. deadline_seconds is required with no default and at most 30: a default would be a number the deployment inherits silently and could not defend, so a block omitting it is refused at load. post_bytes defaults to one MiB. Each entry of tools declares one governed tool: exact versions with no wildcard, the operation_ref it reaches, one target input with a kind of canonical, hostname or provider_id, an optional incident input, the inputs allowlist, an arguments map into the action's typed arguments, and an optional expect that may only deny. ungoverned_toolkits names toolkits that pass with no kernel call at all. Three operator rules matter more than the shape. The incident id is a claim the agent supplies, never a fact the platform asserts: the kernel checks it against the resolved target under the mapping's own tenant, account, provider and resource type, so a claim naming another tenant's incident, another account's or another resource's is refused, and an incident standing against the right target in the wrong state is the tenant's policy pack refusing it on the frozen incident_status and incident_severity facts, not the kernel. A released contract needs the declaration at all: each of the four the production catalog releases sets incident="required". A precondition class declared for a binding an Arcade deployment serves is an overclaim, because no version reaches Arcade (the hook response has no field for a grant and the extension sets no override), so declare none for such a binding and accept UNAVAILABLE; a binding served through both a Gate story and Arcade has one declaration for both carriers and must take the weaker. A changed arcade block is not a key rotation: the block is part of the configuration shape, so a reload that finds it changed reports refused and leaves the running keys and the running tool map untouched, and the change needs a restart. Beside the refusals every invalid file gets, the block is refused for an operation_ref outside the character class ActionRequest.operation_ref itself admits, a toolkit named as both governed and ungoverned, a target.input or incident.input outside its own inputs, an argument mapped from an undeclared input, two inputs mapped to one argument, and a duplicate tool declaration; a deployment carrying an arcade block may declare no connectors at all. telemetry (optional, default off) records whether this deployment would send ExecBound one weekly usage report. It is deployment-wide rather than per tenant, which is why it is here beside the connectors, and execbound init asks the question once and writes the answer. Nothing reads it yet: no report is composed, no schedule exists and no endpoint receives one, so the block is an answer recorded where the decision lives and not a feature. console is accepted and ignored: the console is served whatever it says (see how the console is served), and a file that still carries the block is named in one configuration.ignored_block event rather than refused, so a gateway never fails to start over a file nobody has edited yet. service (optional) names where this deployment sends people for help, every field optional: support_email, which the MFA pages' "Get account help", the console's Help page and My account write to, and help_url, status_url, privacy_url, terms_url, plans_url, security_url and privacy_requests_url, which the page footers, the sign-in page's "Forgot your password or lost access?", the console's Help page and Workspace settings' requests card link to. source_url is the footers' Source link, where the code the deployment runs can be read, and the one with a default: the public repository, https://github.com/ExecBound/execbound, kept when the block leaves it out. A deployment that runs changed code names its own copy, which helps it meet the AGPL's network clause, and null hides the link. operator names who runs the deployment, at most 80 letters, digits, spaces and .,&'()-: the footers show "© 2026 <operator>" when it is set and "Powered by ExecBound" when it is not. Any other link with no address is not shown, so a deployment without the block shows Source alone. Each URL is https:// with a host, no user information and at most 200 characters, and the address is one @ between a local part and a domain; anything else, or an unknown field, refuses the file. A loaded plugin may give these instead, and a file with the block beside such a plugin is refused at start. A changed service block is not a key rotation: a reload that finds it changed reports refused, and the change needs a restart. plugins (optional, up to 8) names the first-party plugins this gateway loads, each with its own section, which that plugin validates; a self-hosted gateway needs none (Without a plugin). A changed plugins block is not a key rotation: a reload that finds it changed reports refused, and the change needs a restart. key_reload_seconds (0 to 3600, default 0) makes the gateway re-read the file at that interval and rotate the key rings when only they changed; see Operations. The configured origin controls Host/Origin validation and the fixed approval URL. Run the separately configured mock using PROVIDER.md.
Existing endpoint configurations may omit provider; its default remains mock_crowdstrike. An identity connector must explicitly use mock_entra with its separate account, credentials, signing keys and provider process. See IDENTITY_PROVIDER.md. Human lists, decisions and rendered action names use the frozen canonical action; an assignment for disable cannot approve enable or revoke sessions.
sample_providers (optional) serves sample targets. It is keyed by provider, and this release accepts only mock_crowdstrike. Each entry names the mock's origin, which obeys the rules a connector's origin obeys, a sample_key of 32 to 128 random bytes written as lower-case hex, and timeout_seconds (default 5, at most 30). For an account whose row is active and marked sample, which only the samples_install function can mark, the gateway derives the dispatch token, the control token and the journal key from the sample key, the tenant and the account, builds the connector when a request needs it and closes it afterwards; an account that is not marked sample never gets one, and a configured connector resolves exactly as before. A sample dispatch records ENFORCED_BY_PROVIDER only when the preconditions block beside this one also declares mock_crowdstrike's contain and lift_containment that way, exactly as bootstrap.MOCK_PRECONDITIONS does for this deployment; with no such declaration a sample dispatch answers UNAVAILABLE like any other undeclared binding. The endpoint mock holds the same key as EXECBOUND_MOCK_SAMPLE_KEY (PROVIDER.md). The key must differ from every connector evidence key, continuation key and checkpoint journal key, compared as HMAC-SHA256 uses a key, and may not be a published test key, checked at start and on every rotation. Changing this block is not a key rotation: a reload that finds it changed reports refused, the change needs a restart, and a new key cuts every workspace's sample connection until the mock holds the same key. Containment is per tenant: the mock refuses a sample token for any tenant it already holds a configured account for, whatever account id the command names, and samples_install refuses a workspace that already holds an active connector account, so a sample account can exist only in a workspace with none. The gateway mirrors the mock: in a tenant this file holds a connector for, it derives no sample connection and offers no samples, even where a sample account remains because the slot was switched off in the database but not here, so a sample request there is refused at admission instead of being sent to a 401 and left INDETERMINATE. Whoever holds the key can act on every workspace's sample endpoint mock and on nothing else (Threat model T3.9, R28).
uv run uvicorn execbound.app:create_app --factory --host 127.0.0.1 --port 8000 --no-access-log
Without EXECBOUND_RUNTIME_CONFIG, the factory exposes the foundation health and identity endpoints and the two credential-gated intakes, POST /v1/monitor/events for monitoring sources and POST /v1/signals for security signal producers, and nothing else. Dependency-injected test runtimes do not read that environment configuration. No route accepts connector configuration or credentials from an agent. Runtime-created HTTP clients close with application lifespan.
Verification and limits
uv sync --locked
uv pip install --target .tools/mcp-legacy --require-hashes -r tests/mcp-legacy.txt
uv pip install --target .tools/sdk-examples --require-hashes --no-binary claude-agent-sdk -r tests/sdk-examples.txt
uv run playwright install chromium
uv run pytest -q
CI installs Chromium with its OS dependencies and runs the same suite. Browser tests use real Uvicorn, guarded PostgreSQL, and the separate authenticated mock. Credential entry, trace/HAR files and browser storage are not captured. Synthetic review screenshots go to the test's temporary ui-review directory, independent of Docker's data-directory ownership. Test failures use short tracebacks to avoid automatic fixture-argument dumps.
The application bounds HTTP action bodies to the action parser's limit, MCP envelopes to 24 KiB, form bodies to 16 KiB and body reads to five seconds. It admits at most 32 concurrent HTTP requests per process and as many executing runtime calls as the database pool can serve (13 by default), and refuses requests beyond fixed per-credential, per-sign-in and per-peer windows with RATE_LIMITED and Retry-After before any lookup or admission; the private configuration's optional limits object tunes those windows (see Operations). These limits complement PostgreSQL's authoritative shared impact accounting; they are not a distributed rate-limiting service.
Offline replay has its own guide. A configured gateway process and its cross-tenant database credential remain trusted components.
Rules
/policy/{bundle_id}/rules is the ADMIN editor for rulesets. It is a console page over GET and POST /api/console/v1/policy/{bundle_id}/rules, described below: the rules apply to the actions the draft's mappings serve, the form offers only the facts, arguments and values those actions' contracts declare, and each change writes a successor draft and retires the one it replaced. A rule is compiled with the draft before it is accepted, so an unknown fact or a value outside its domain is refused with its location at the form, not only at validation. Creating the draft goes through ruleset_bundle() and the store, so the page never writes a component and the store's mismatch check is the safety property. The bundle page shows a ruleset bundle's rules as sentences, its notes as text, the compiled Cedar read only, and names the rule beside each preview change and each quorum obligation. An approver is refused with 403. The audit chain carries policy.ruleset_compiled beside policy.bundle_created, with counts and never the rules' text. A ruleset bundle's rules also download as the file ruleset-compile reads, and such a file starts a draft in another workspace (below).
Other pages read the rules too. The agent page carries a Rules column beside each governed action, the rules about that action in the compiler's order. Every reading is labelled as a reading ("Rules are as they read for the action; the decision is the kernel's, from the compiled bundle."), and tests/test_pack_rulesets.py proves the reading equal to the kernel's policy decision over the whole input space of every pack, tests/test_rule_reading_operators.py over the operators the packs do not write; a bundle written as a document adds no Rules column and no claim. /policy/rules redirects to /policy, where Customize current protections copies the active bundle, its rules included, into a draft (POST /api/console/v1/policy with {"source": "active"}).
How the console is served
The console is a single-page application. It is not optional and has no flag: a deployment whose private configuration still carries a console block has it accepted, ignored and named in one configuration.ignored_block event, so a gateway never refuses to start over a file a person has not edited yet.
Each of the console's routes serves the application's one document to a signed-in person: a visit with no session or an ended one is sent to /login; a bearer credential is refused with 403 and audited as execution.rejected, as on every page; a duplicated query key is refused, and every other key is the application's own state, which the server never reads. The document is no-store and carries the running commit in <meta name="execbound-release">, so the client can tell a release it was not built for. GET /console/{file} serves the build's own files: the hashed ones under assets/ as public, max-age=31536000, immutable, the rest the build ships (the token stylesheet, the plain frame, the fonts) revalidating by ETag; build metadata and any file of a type the server does not know are not served, and the document is never one of them.
Every console route and every /api/console/ response carries the console's policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; require-trusted-types-for 'script'; trusted-types 'none'. It is chosen by matching the console's own route list rather than by a path prefix, because the console and the pages that stay now share the address space: /approvals/{id} is the application and /approvals/{id}/classic is the page behind it. Every other response keeps the pages' policy, which allows font-src 'self' so those pages can take the packaged IBM Plex, and img-src 'self' so the mark every page links is not refused by its own default-src 'none'. A package that carries no build answers 503 with a plain sentence on every console route, so a missing build is never mistaken for a path that does not exist; the pages that stay answer as they always did. The build itself is produced by console/ (Vite, React 19, TypeScript, pnpm 11 under the supply-chain settings console/DEPENDENCIES.md records) and written into the package; tests/test_console_serving.py, tests/test_console_dependencies.py and tests/test_route_authority.py hold the behaviour above.
Editions. The console is built from one source tree. The core edition, pnpm build (vite build), is what the package, the wheel and the self-hosted image carry; a plugin's console edition is built from the same tree with its own directory added, and an image built with another CONSOLE_EDITION (Deployment) carries that build instead. Core's pages read what an edition adds through slots, one React context (console/src/edition/context.ts) whose default is the core edition, where every slot is empty: navigation, entries in the sidebar, the phone's tab bar, the palette and the breadcrumbs, after core's main entries and before the footer entries, and in the sidebar within the group each names; TopBarItems, among the top bar's actions, after the mode and the emergency stop and before Ask; paletteLinks, server pages in the command palette's own "Actions" group, before "Go to", each offered only where the session's features hold its own feature; WorkspacePanels, cards after the data and closure requests on Workspace settings; TenantDefinitions, definitions after the active bundle in Workspace settings' "What this tenant is"; AccountPanels, cards between the profile and the data requests on My account; Welcome, the end of Getting started, after its steps; FixtureHint, the example request's target on "Connect this agent" where core names no workstation; ApprovalRemedy, the sentence after Workspace settings' approval readiness warning, null in core, which keeps core's own sentence; and event, which core calls when something countable happens and which counts nothing in core. Two flat flags say what setup may offer, each a top-level boolean and neither wrapped in an object: operatorCommands, whether the people reading the console also run this deployment's operator commands, so Monitoring and Getting started may show them; and watchFirst, whether an administrator can connect a monitoring source by following the console's own setup dialog, so Getting started and Home may offer watching first. Both are true in core. The hosted service's console edition fills them; what it adds is documented with the hosted service. Only console/src/main.tsx imports the two aliases that choose an edition, @edition for the slots and @route-tree for the routes, and it provides the edition around the router. The core build lists every module it loaded and fails if one lies under console/src/hosted/ (console/scripts/editions.ts), and ESLint refuses an import of a hosted module, static or lazy, in every core file under console/src/ and console/scripts/, and either alias in any of those core files but src/main.tsx (console/eslint.config.js). The server lists core's pages in CONSOLE_ROUTES. A plugin registers its own console pages with registry.console_path, and the workspace-scoped approval addresses, the console's /workspaces/{tenant_id}/approvals/{approval_id} and the classic /workspaces/{tenant_id}/approvals/{approval_id}/classic, are mounted only where a loaded plugin registered approval links; without such a plugin each answers 404 under the pages' policy. /health/ready reports "console": "<edition>", the edition the served build names in its document, read once when the application is built, in a process that serves the console; it reports the edition and refuses nothing over it. tests/test_console_serving.py holds the behaviour above, with CE1 to CE15 in the acceptance inventory.
The console API lives under /api/console/v1/. It answers two callers: a signed-in person's session, and an administrator's own personal access token, Authorization: Bearer ebp1.<tenant>.<token>.<secret>, minted from My account → Personal access tokens and shown once. Minting one steps up any second factor the administrator has enrolled (require_recent_factor is a no-op with none enrolled, so an administrator without one is not asked for one). Every route runs the pages' own boundary regardless of which caller it is (the query allowlist, the Host pin, the identity-header refusal), any other bearer credential (an agent's eb1 key among them) refused with 403 and audited as execution.rejected. A session request loads the session from its cookie, checks the CSRF cookie, and additionally needs, on a mutation, Content-Type: application/json, a body of at most 64 KB with no duplicate key and no field its model does not declare, and an X-CSRF-Token header equal to the session's token, a mismatch being audited as INVALID_CSRF; the exact Origin check and the workspace-generation header are the pages' own. A token request skips the Origin check, CSRF and the workspace-generation header, because they defend a cookie a browser sends on its own and a session that can switch workspace, and a token is neither; it keeps the body rules above, is looked up by its own transaction and rechecked as a session-shaped authority (personal_tokens.TokenSession), and must name a route token_rules.token_allowed admits, from core's own table or, for a loaded plugin's own console routes, that plugin's classification (token_routes), or is refused 403 TOKEN_NOT_ALLOWED, audited. A token has its own 300-request-a-minute rate limit, may name a space with X-ExecBound-Space (unscoped, it sees every space its owner can), and is refused with DIRECTORY_REQUIRED in a workspace that requires sign-in through an identity provider; a request carrying both a session cookie and a bearer token is refused rather than resolving one. A refusal is JSON, {"code": ..., "detail": ...}, from a closed list of codes the generated client maps to copy, and it is JSON to a browser too: the HTML error pages never answer on this prefix. tests/test_console_api_tokens.py, tests/test_route_authority.py and tests/test_personal_tokens.py hold the token half of this boundary; PT1 to PT13 in the acceptance inventory. Every response carries X-ExecBound-Release, the running commit, so the client can tell a release it was not built for. GET /api/console/v1/session returns the signed-in person (principal, kind, label), the tenant (name, mode, approval deadline), the tenant's active emergency stop (id, when, by whom, the reason) or null, read as the pages read it for their banner so the console shows the same banner on every route and an approver sees the state, the session's expiry and its hard end (ends_at, eight hours after signing in or the credential's own expiry when that is sooner, which no renewal moves, and end_reason, sign_in or setup_credential for a session a setup credential opened that ends on it), the release and the CSRF token, which is the one place the token leaves the server as data, under Cache-Control: no-store. It also carries invitations, whether the session feature of that name holds, and features, the sorted names of every loaded plugin's session feature that holds for this session, invitations among them where it holds, which is [] without a plugin; a plugin's console edition reads its own names from that list. It carries service too, the deployment's service links with each of its eight fields null where the deployment names none (configuration), which the console's Help page, footer, My account and requests card read. POST /api/console/v1/client-errors takes {route, release, name, stack_hash} of bounded shapes, never a message text, and writes one console.client_error log event; the console's route boundary sends one for a page that fails other than by a typed refusal (below, The console's shell). The committed OpenAPI document, console/openapi/console-v1.json, holds core's areas only and is the published one, and the client at console/src/api is generated from it. A plugin's console areas have their own document, console/src/<plugin>/openapi/console-<plugin>-v1.json, from which that plugin's console edition generates its own client. uv run python scripts/console_openapi.py --plugin <plugin> writes both, and --check compares both; tests/test_console_api_base.py holds core's equal to what the router builds, and the console CI job generates both clients from them. tests/test_console_api_base.py and tests/console_api_fixtures.py hold the behaviour above and are the harness every area's routes reuse.
The step-up is asked for in place (decided 28 September 2026). A session mutation under /api/console/v1/access/, /people/, /credentials, /invitations, /teammate-invitations, /tenant/sign-in, /settings/password, /settings/tokens, /account/requests, /approval-rules, /approvers/ or /tenant/mode (in both directions), an agent's credential routes, a space's membership routes, a policy's activation (/policy/{bundle_id}/activate), or exactly /samples or /samples/refresh (which install and activate the starter policy; /samples/remove only tightens and is not asked), calls mfa.require_recent_factor, which answers 401 MFA_REQUIRED when the person has an enabled factor last verified on this session more than thirty minutes ago (mfa.STEP_UP_WINDOW; the account security page's own changes, passkeys and provider links read the same constant, and since 0067_step_up_window so does the definer functions' own check beneath them). The emergency stop and its release (/tenant/stop, /tenant/resume) are not asked, so an administrator can halt every agent without an authenticator to hand (SU15). The console then opens a dialog over the page asking for an authenticator or recovery code, and sends it to POST /api/console/v1/session/verify with {code} (at most 64 characters). That route is a session mutation in every other respect (the cookie, the exact Origin, the CSRF header, JSON and the workspace generation; a personal access token is refused TOKEN_NOT_ALLOWED) and verifies nothing a new way: it calls the security page's own mfa.verify_factor, with its single-use time step, recovery digests, five-attempt lock across sessions and account_security.* audit events, then mfa.rotate_verified_session, sets the new session and CSRF cookies, and answers {csrf_token} under no-store. A code that cannot be used answers 400 INVALID_FACTOR; a factor removed meanwhile, 409 CONFLICT; a deployment without account security, 503 UNAVAILABLE. While the code is checked the client holds new requests and waits, at most five seconds, for those already sent, so nothing carries the session cookie being replaced; it then sends the refused change again exactly once with the new token, and a change resent with the old one is refused INVALID_CSRF. Cancel leaves the change undone and the page as it was, with the page's refusal sentence saying so; a second MFA_REQUIRED is shown, never asked again. A session still at the second step of signing in is refused MFA_REQUIRED by the session guard before this route runs, so a read or a document that needs MFA at sign-in still goes to /mfa. First-time authenticator setup and provider linking still need a sign-in within five minutes, which is the session's own age and not a factor's. tests/test_console_api_step_up.py, tests/test_mfa_sessions.py, console/src/lib/client-config.test.ts, console/src/components/StepUpDialog.test.tsx and tests/test_browser_console_step_up.py hold this; SU1 to SU15 in the acceptance inventory. A saved passkey satisfies it too: POST /api/console/v1/session/verify/passkey/options (body {}) calls the security page's passkeys.begin for this session and answers {options}, the WebAuthn request options with a single-use 32-byte challenge stored for this session for five minutes, or 409 CONFLICT when the account has no passkey; POST /api/console/v1/session/verify/passkey (body {credential}, a strict, bounded assertion) hands it to passkeys.complete and, on success, rotates and answers exactly as /session/verify does. Both are session mutations under the same guards and refuse a personal access token. Each finish attempt that finds a challenge spends it; an assertion that fails verification answers 400 INVALID_FACTOR, counts toward the five-attempt lock and is audited, while a missing, spent or expired challenge and a locked factor answer 400 with neither. The dialog fetches options as it opens where the browser has PublicKeyCredential and the account a passkey, calls navigator.credentials.get inside the click, and fetches fresh options after every attempt; its first line names the refused change from console/src/lib/step-up-purpose.ts, a table keyed by method and path template with numeric handles only, which tests/test_console_step_up_purposes.py holds to every core step-up route the document serves. The browser test serves the console over https://localhost with a throwaway certificate (serve(..., tls=True) in tests/test_browser_surfaces.py) so a virtual authenticator can answer.
GET /api/console/v1/board is the live board as one value: the agent and target lanes with their server-assigned indexes, every shot with its phase, word, detail and age in tenths of a second (a request refused before a decision is an execution.rejected event from admission alone, never a reviewer's, an administrator's, a report's or a surface's refusal, and has the event's recorded reason as its detail, such as INVALID_OPERATION or INVALID_TARGET, which the ticker words as a refusal before any plan rather than as a decision), the ticker, the overflow counts, the counters for today (from the viewer's midnight when the console sends utc_offset, else UTC midnight) and for the fifteen-minute window, and the monitoring band when the tenant has something watching. It is surfaces/board.py's own read, under the same REPEATABLE READ snapshot, the same identity and session rechecks and the same approver scope, so an approver sees their assigned targets and no band, and it changes nothing. Limit refusals and requests refused before a decision are counted for the window and are null for the day, which says "not counted for this period" rather than zero. A limit refusal is a denial whose recorded codes include a spent limit's HARD_LIMIT:<rule id>, whatever sits beside it, which is the rule that tells the denied agent limit (store.denial_category); its shot's word is limit and the ticker says an impact limit blocked it. GET /api/console/v1/board/stream is the same snapshot as server-sent events: a board event whenever the board has actually changed, compared on a digest that leaves the ages out so an ageing board is not a changed one; a comment every fifteen seconds while nothing changes; a closed event with the reason session when the session ends, the credential is revoked or the role changes, which is the only answer left once the headers have gone out; and a retry line so a dropped connection comes back on its own. The stream re-reads the board every five seconds, which is also how often it rechecks the session, and one connection lives at most fifteen minutes. Both routes take the session cookie only and no query key but their own: GET /board takes window and utc_offset, the stream generation and utc_offset; ?live=0 is the server-rendered page's, and pausing an application is the client's own business. tests/test_console_api_board.py holds both, the stream over a real gateway because the test client collects a whole response before handing it back.
Times and names on the console API. Three reads take the viewer's distance from UTC as utc_offset, minutes east of UTC as the browser reports it at the viewer's most recent midnight: GET /board, GET /board/stream and GET /approvals (read only by view=decided), beside GET /overview, which already did. One parser, console_api/viewer_time.py, refuses anything but up to three digits with an optional minus within fourteen hours of UTC, as INVALID_REQUEST before any read and before the stream's first byte; absent means UTC midnight, which is what an old client and the server-rendered showcase count from. One rule, dashboard.viewer_midnight, gives Home, the board and Decided today the same day boundary. A held shot carries expires_at, its approval's deadline as an instant the console prints in the viewer's zone; the detail keeps its "answer by HH:MM:SS UTC" for the server-rendered page. Every principal the console API hands the console carries its kind and its workspace counting number (migrations 0058 and 0062) beside its label: an execution's and a timeline's agent and actor (AgentView gains kind and number), an approval's agent (number) and each recorded decision (kind, number), and a PrincipalRef (principal_id, kind, label, number) on a stop (/session's stopped_by_ref, filled for an administrator's session alone and null for an approver's; /tenant's stopped_by_ref), a mode or deadline change (by_ref), an acknowledged finding (acknowledged_by_ref), a binding (bound_by_ref), and a queued account request (number, principal_kind). An approval item names its request's state in words as execution_state_label. An agent lane's label is the principal's name (its label, else Agent 3) and its handle is Agent 3 beside an administrator's label, null otherwise; the approval detail's sentence names the agent the same way, and the timeline's decision reads "Approved by User 2." Every UUID field stays beside its reference. The facts are read by surfaces/names.py's principal_facts on the caller's fenced connection, for ids taken from rows the caller already read in scope; nothing an agent receives carries a number, a kind or a principal's name.
The settings area is seven routes over the functions the two Jinja settings pages call, beside the password, weekly-summary and trusted-browser routes. GET /api/console/v1/settings is personal and answers both human kinds with the acting principal's own rows only: the principal, its kind and the credential it signed in with, the tenant's name, the credential's issue and expiry times, every live session of that principal (id, created, expires, the fixed device label, whether it is this one) with a count of the others, a security block whose trusted list holds the account's live trusted browsers (browser id, label, created, last used and expiry times, and this_browser, read from the browser id in the request's trust cookie, never the cookie's secret or hash) and whose trust_allowed says whether the workspace allows them, and a directory block (directory_store.own_directory): provider, the tenant's enabled provider as id, name and state, null when none is enabled or the deployment holds no directory key ring, and link, the principal's own active link (provider id and name, subject, via, the two display hints, created and last sign-in times) or null; POST /api/console/v1/settings/sessions/revoke-others takes an empty body and is revoke_other_sessions, ending every other session of that principal inside the tenant fence with one human_session.revoked event of reason OTHER_DEVICES per row, and answers the fresh view. POST .../settings/trusted-browsers/remove ({browser_id}) and .../settings/trusted-browsers/remove-all (an empty body) are trusted_browsers.remove and remove_all: neither is a step-up prefix, both answer the fresh view, a browser id that is gone or was never the account's changes nothing, and each clears the trust cookie when the trust it names was removed; a personal access token is refused both, as it is every route of the owner's own account (tests/test_console_api_trusted_browsers.py). GET /api/console/v1/tenant is an administrator's (kind="ADMIN" on the guard, so an approver is refused 403 FORBIDDEN before any body is read) and is tenant_overview: the tenant, the mode with its last change, the deadline with its bounds and last change, the active stop with the committed count recorded at activation, the active bundle, the monitoring counts, and the configured approval destinations and the security event export as id, kind, host, classes and how many approvers, never the URL or the secret. POST .../tenant/mode ({mode, reason}), .../tenant/deadline ({deadline, reason}, the deadline as typed, off or seconds, read by the page's own parser so 119, 3601 and a word answer 400 INVALID_BODY naming deadline and write nothing), .../tenant/stop and .../tenant/resume ({reason}) call set_enforcement_mode, set_approval_deadline, activate_stop and release_stop with the session's identity, so the audit events are the command line's key for key; an administrator narrowed to spaces is refused by the store function before anything is written and answered 403 FORBIDDEN, and still reads GET .../tenant; a no-op answers 409 CONFLICT, an empty reason, or one shaped like a password, key or token, 400 naming reason, and each answers the fresh tenant view. A mutation's body model is in the document (console_api/bodies.py) so the generated client's body is typed. tests/test_console_api_settings.py and tests/test_browser_console_tenant.py hold the behaviour above; the pages are /settings and /tenant.
The weekly summary. GET /api/console/v1/settings also carries weekly_summary: whether this deployment sends the summary (available, its mail block has weekly_summary: true), whether the person has it on, and the address it goes to, resolved inside the tenant fence from the account the person reaches the workspace through, else the principal's own address, and null where none resolves. POST /api/console/v1/settings/weekly-summary takes {subscribed: bool} and nothing else, turns the person's own summary on or off, appends weekly_summary.subscribed or weekly_summary.unsubscribed (via: settings) and answers the fresh view; it is 503 UNAVAILABLE where the deployment does not send the summary, 409 CONFLICT for a no-op or for turning it on with no address, and refused to a personal access token. A summary is stopped without signing in at GET/POST /weekly-summary/unsubscribe/{link}: the GET reads and changes nothing, the POST ends the subscription only for that message's own secret and answers every request with the same page, a database fault included, and both send Referrer-Policy: no-referrer. So do their 400 and 429 refusals, whose pages load the stylesheet and link to sign in from the address that holds the secret.
Second-factor status. GET /api/console/v1/settings also carries security: authenticator (an enabled authenticator app), passkeys (how many are saved), recovery_remaining (zero without an authenticator), requirement (optional, admins or all) and available (whether this deployment has account security at all). mfa.factor_summary reads them inside the view's own transaction through the step-up's own _factor and saved_passkeys, returning counts and flags only, never a seed, digest or passkey id. A personal access token reads its owner's, as the route is token-allowed.
The approval routes are the approval pages over JSON. Every human account, an administrator included, decides only what an explicit review assignment covers; an administrator may also read what waits on other people, and never decide it. GET /api/console/v1/approvals lists the reader's queue through records() under the same scope, fence and bound as the Approvals page, fifty at a time with an opaque cursor; ?view=decided lists instead the approvals whose final decision landed today (UTC), one more predicate inside the same read (decided_since), so the list has exactly the Activity page's authority and no read of its own. The default ?view=pending is what this approver can still decide, so it holds only approvals whose deadline has not passed, and ?view=lapsed holds the other half of that set, the approvals that ran out of time undecided; one clock decides both inside the same read, so an approval is in exactly one of them and never in neither. An approval that lapsed is a fact somebody should be able to read, so it moves between views rather than out of every one, and /overview's awaiting list, which is the same read, drops it with the queue. ?view=others is an administrator's "Waiting on others": the pending approvals in the spaces they are looking at that they are not assigned to decide, one more predicate inside the same read, the negation of the reviewer predicate, which records() itself refuses to any other reader; an approver asking for it is refused FORBIDDEN. An administrator's ?view=lapsed reads every visible space rather than their own assignments, since a lapsed approval can never be decided. Each item carries the action and its label, the target, the resource class and environment, the agent with its label when one is set, the expiry with which clock set it and the seconds the approval was given, the approval and execution states, the quorum required and recorded, and this reader's own decision when one is on record; for an administrator reader it also carries reviewers (how many people hold an assignment that lets them decide it and can open it in its space), reviewers_left (how many of those have not decided it) and assigned_to_you, computed in one statement over the rows the read has just returned, and null for every other reader. Every item also carries delivery, the reader's own approval message against the deadline, null where no destination was ever queued one: counts over the approval's announcements of delivered, pending, failed, lapsed (closed unsent because the approval lapsed first, whether the drain's margin or the recovery run's sweep got there before the message), closed (the approval was decided first, or approved and then left unused) and withdrawn (the request stopped being valid before anyone decided it), with last_delivered_at, margin_seconds (the deadline less that delivery in whole seconds, negative when it arrived after) and late (a message lapsed unsent, or arrived within 30 seconds of the deadline or after it). The older notified words stay as they were, never counting a lapsed message. The response carries the server's time so the client can count down from the server's expiry. GET /api/console/v1/approvals/{approval_id} is one approval in words: the frozen plan as a sentence, the requested arguments, every trusted fact with its origin, source, permitted writers, observation time, validity and age, every obligation with its kind, the rule that raised it and that rule's sentence when the bundle carries a ruleset (or the fixed sentence the page shows otherwise), the quorum with the decisions recorded, the deadline, what a dispatch would do and whether the catalog names an action that reverses it (ActionSpec.reversed_by, see the action catalog), the applicable limits, the externally executed and platform-carried notices as text, the tenant's mode, the active stop, a status of pending, decided, expired, paused or settled, reviewing, your_review_elsewhere (an administrator's own review of this action and target held in another space, which assigning themselves here would move; null otherwise and for every other reader), link_path (this deployment's own address for the approval, built from identifiers only: /approvals/{approval_id}, or the workspace-scoped address a loaded plugin registered for approval links), and, only while pending and only for a read through the reader's own review scope, the signed continuation the decision must carry, the same one the form's hidden field holds. An administrator not assigned to the approval reads it as a named record, by membership, with reviewing false and no continuation. An unknown or foreign approval is NOT_FOUND, and so is one outside an approver's scope or outside an administrator's spaces. POST /api/console/v1/approvals/{approval_id}/decision takes {decision, comment, continuation} (APPROVE or DENY, a comment of at most 500 characters, the continuation from the detail) and calls decide_approval exactly as the form does, with the session rechecked inside the kernel's transaction; the continuation is verified against the session, its CSRF token, the execution, the approval and the frozen plan before the kernel is reached, and a failure is audited as INVALID_CONTINUATION as the form's is. Refusals are typed: an active stop is STOPPED (423) from the kernel; an expired approval is APPROVAL_EXPIRED (409), audited as the form's expired token is; this approver's second decision is ALREADY_DECIDED (409) and any other state change CONFLICT (409); a record the continuation names that the approver can no longer see is NOT_FOUND; a forged or mismatched continuation is INVALID_CONTINUATION (400); a comment the kernel refuses is INVALID_BODY naming comment, audited by the kernel as today. The answer is the decision with the approval's and the execution's state and the quorum recorded, never optimistic. tests/test_console_api_approvals.py holds the behaviour above.
The console's Approvals page lives at /approvals and /approvals/{approval_id}, reading only the three routes above. On a desktop the queue (Pending, Waiting on others for an administrator, Decided today, and Lapsed, each with its count) sits beside the detail where both fit, and above it where they do not, so the detail never narrows to a sliver at tablet widths. Decided today, like Pending, reads the approvals the person is assigned to review, an administrator included, and its empty state says so. The view is the address's ?view=, so Back, a reload and a shared link return to the same chip, the default Pending never appears in the address, and a view the session cannot use reads as Pending and leaves the address without a new history entry; a row opens its approval in the URL with the view kept, so a link or the back button restores both; a row whose deadline has passed reads Lapsed with the moment it lapsed, never a ring counting down to 0:00; on a phone the card comes first, what else is in the same view follows, and the two decision buttons are pinned above the tab bar at equal widths and 50 px, each tinted in its own tone so neither choice carries more emphasis. Above the buttons the decision bar says where and what: "Deciding in" the workspace (and the space where the tenant has more than one), always on a phone and on a wider screen where the person can hold more than one workspace or space, then what reverses the action, the deadline and the approvals recorded; each button says on a second line what it does, and Approve's accessible name carries the target and the workspace. The comment field takes a row of its own above the two buttons, and shares their row only where the detail is wide enough to leave it room to read. The detail's eyebrow names the workspace for every reader and says "Waiting for you" only to someone who can decide, at every width; a lapsed approval's eyebrow and execution line read as lapsed, never as waiting. A comment typed in the bar is kept in that browser tab, bound to the person and the approval's deadline, and restored after signing in again; it is forgotten on a decision, on a refusal that ends the approval, on a secret-shaped refusal and on signing out. An approval opened from a notification is its own route chunk and loads nothing Home would, inside the 160 KB budget the build enforces. The detail is the server's answer as text: the frozen plan as a sentence, the rule that held it with its sentence, the trusted facts with their source and age, what a dispatch would do and whether the catalog reverses it, the quorum and the decisions recorded, and a collapsed "Explain this request" placeholder for the assistant. The deadline ring counts down from the server's expiry, corrected by the offset between the phone's clock and the Date header of every API response, so a wrong clock still shows the time the server will give. A decision is never optimistic: both buttons wait for the server, the answer is a toast and the next item, and a refusal shows its typed reason and the reloaded state, which says why (paused, expired, decided, settled). Under the quorum, the detail's delivery line says when the message reached its approvers against the deadline ("Notification delivered 14:08:31 EDT, 2 min before the deadline."), and tells a message that was never sent apart by why: the approval lapsed first, it was decided first, or the request was no longer valid. An approval past its deadline reads as lapsed before the recovery run ends it as after, and to someone who is not an administrator it says which of its histories it had (lapsed before anyone decided it, before enough people approved it, or approved but never used by the agent), that nothing ran where that is known, and that the agent must send the request again; a decision refused as lapsed says the same, and an administrator keeps the advice to read the execution history. An administrator's Lapsed view flags each row whose message lapsed unsent or arrived within 30 seconds of the deadline, on the approval deadline's clock only, with one line above the list saying how many and that a longer approval deadline gives people time to answer, linking to Workspace settings only for someone who can change it. An administrator whose Pending is empty while requests wait on others is told they review only what they are assigned to, with the way to Waiting on others and to who reviews. Each row there shows the time left, the approvals recorded so far and who can decide it, or "No one can approve this", or "Cannot reach the quorum", and whether the message was Delivered, Sending or Not sent; the Waiting panel carries the delivery line. Its detail replaces the decision bar with a panel that says the same in a sentence and offers to assign themselves to review this one request: the person page's own assignment, behind its step-up and audited, offered only in the approval's space and only to a role that may assign reviewers, and, where it would move a review they hold in another space, saying so before the write. The panel also offers "Copy approval link", the validated link_path on the page's own origin, shown as text where the clipboard refuses, to send to whoever should decide it. What only an administrator sees (the panel, the self-assignment, the empty state and the coverage words) is loaded on demand, as are the assistant's disclosure where the deployment has one and the comment store where the tab kept a comment, so an approver's cold link stays inside its budget. The sidebar's and the tab bar's Approvals pill read the pending count every ten seconds while the tab is visible, through the same query the page reads, on the application's one polling timer (console/src/lib/poll.ts), so a tick is one request per endpoint however many components watch it and nothing is asked for while the tab is hidden. tests/test_browser_console_approvals.py holds the page in Chromium (a cold link decided by the keyboard alone, the phone layout and the server's clock, a quorum of two by two people, a decision during a stop refused with the page saying why, an expired approval, a hostile target as text, an administrator chasing an approval waiting on someone else, the phone's decision bar naming the workspace, assigning yourself behind the step-up, a comment kept across signing in again, what an approver reads after a lapse, including one who had approved, the late-lapse note and the delivery line, axe clean with zero policy violations in both schemes at both widths).
The Home and Activity routes wrap the reads their pages make today. GET /api/console/v1/overview?range=24h|7d|30d is the home page's overview read with a window and a bucket: the figures by outcome (executed, denied, held, observed, stale), the series the decisions chart is drawn from, the counts of the window before, the pending approvals and the unsettled count, the first five pending approvals in an approver's scope, and monitoring health for an administrator only; an approver's figures count only the executions their active scopes admit, through the same scope clause as the page. GET /api/console/v1/executions is the activity listing's records read with its filters (state, action, agent, target, since of 1h, 24h, 7d or 30d), a limit of at most fifty and an opaque cursor; an unknown key, value or cursor is refused with INVALID_REQUEST. GET /api/console/v1/executions/{id} is the same read for one record, in words: the action and the target, the state and its word, the reasons with the rule ids they name (a shared limit's id, a review rule's id through the compiled bundle's positional obligations) and a sentence each; for a denial under a bundle written as rules, a sentence naming every deny rule the request matches, or saying that no rule allows it, read from that bundle's rules over the frozen facts and arguments because the kernel records NOT_AUTHORIZED and no rule id, so the view's rule id stays empty (tests/test_pack_rulesets.py holds that reading equal to the kernel's decision on every input of the shipped packs, and tests/test_rule_reading_operators.py for rules written with is_not, at_least and at_most and for the Defender scenario's rules, and a Cedar failure, a missing fact or a bundle without rules leaves the recorded sentence); and for a reconciliation's reason written in words, that note; the arguments, the facts used with their origin, source and the instants they were observed and expire, the applicable limits, the timeline of the record's transitions and the decisions on its approval, the checkpoint and control-point sentences the pages show, the approval with its quorum, and, for an administrator on a row the page offers a settlement for, which kind (provider, record or correction) and a continuation bound to the record. A malformed, unknown, out-of-scope or other tenant's identifier answers 404 alike. POST /api/console/v1/executions/{id}/reconcile takes {continuation, reason, cancel_if_missing, record} from an administrator and is the page's reconcile form: the continuation is verified and its record re-read under the fence by continuation_row, then settle_grant from the executor's signed record for a checkpoint or reconcile_operation through the connector for a dispatch ExecBound made, with the same evidence rules, so a record without evidence stays unsettled; a continuation that does not bind is refused with INVALID_CONTINUATION and audited, a body of the wrong shape for the row with INVALID_BODY, as is a reason shaped like a password, key or token, naming reason on either path, an approver with FORBIDDEN. GET /api/console/v1/search?q= (one to eighty characters) answers at most twenty records for the command palette: targets by hostname or provider id, executions and approvals by the prefix of their id, and agents by label for an administrator, each scoped as its page is. tests/test_console_api_activity.py holds the behaviour above.
Lapses, effect unknown and the value report. The overview classifies an approval past its deadline on the server's clock, as lapsed before a decision (decided_at null) or approved but not used, and writes nothing: such a row counts under stale in the window it lapsed in, on both sides of the recovery run's sweep. pending_approvals is what a person can still decide, lapsed_approvals the lapses before a decision still awaiting the sweep, unwindowed, and last_request_at the newest request in the reader's scope, null when no request has reached a decision. FAILED_EFFECT_UNKNOWN is its own figure, unknown, an open count like unsettled. The board draws a lapsed request hollow, with lapsed or unused on the shot and lapsed among the counters, and the execution views carry approval.lapsed and approval.unused, the record's bundle_id, policy_readable (whether the rules read of that bundle would answer this reader) and, on each reason, rules, the rule ids its sentence names. GET /api/console/v1/report?range=&utc_offset= counts requests made in the window, in the reader's scope, by outcome: refused by policy, by a limit and as busy with the top three actions, held and what became of them with the median time to decide, uncertain dispatches settled, still unsettled and with an unknown effect, observe mode's would-have counts, refusals before a decision for a reader not narrowed to approval scopes or a space, and an administrator's monitoring health. GET /api/console/v1/report.csv is the same read as section,measure,value rows, every cell quoted and a formula-shaped cell prefixed with ', under no-store, taking the session's generation as the board stream does. GET /api/console/v1/executions takes outcome, one of the report's keys, and from, an instant with its zone at most 31 days back, never beside since, so each count opens exactly the list it counts.
/api/console/v1/agents is the Agents page as JSON, every route a wrapper over the function the page in surfaces/agents_pages.py calls, under an administrator's session and the same fence and bounds; an approver answers 403, a personal access token follows token_rules.py for this area and any other bearer credential is refused and audited, and a cross-tenant, human or malformed id answers 404, as every change does for an agent the administrator may not administer (require_agent, since 0072). GET /agents is the page's list with its filters (state, active, population, source, ungoverned) and its cursor at fifty, declared for the generated client but read by the handler under the pages' query allowlist, so a value outside the choices answers INVALID_REQUEST with nothing echoed back; the answer carries the rows, the next cursor, the active-governed-this-month figure with its month, the tenant line with its source denominators, and space_dormant, the asleep agents of the Space the list is read under, whatever its filters. POST /agents creates a labelled agent. GET /agents/{row_id} resolves an id the way the page does: a principal, a discovered agent, or a bound agent's id answered with its principal's detail, whose row_id says so and whose address the client then follows; the detail carries identity, binding, what the source lists (sponsors as object id prefixes), credentials, reach, the last twenty requests and active runs, groups, the month's count, the limits counted the way admission counts them, and the options the bind form offers. The changes are POST /agents/{principal_id}/label, .../credentials/issue (a lifetime and, for Rotate, an optional replaces naming a live key of the same agent, recorded by admin_credential_replaces in the issue's own transaction so that any other key, a revoked, deleted or expired one, the new key itself or a key another live key already names answers 409 CONFLICT with nothing issued; answering the show-once credential under no-store, with replaces in credential.issued only when it was sent), .../credentials/{credential_id}/revoke, .../deactivate (a reason), .../reactivate, .../unbind (a reason), POST /agents/{row_id}/bind (the other side's id, from either side) and POST /agents/{row_id}/govern (a lifetime, on a discovered agent no binding claims; the principal, its label, the binding and the credential in one transaction, answered with the credential once and the install and MCP recipes, which never carry it). Every body is a strict model declared in the document the same way; a definer function's refusal maps to a typed code (INVALID_BODY naming the field, a label or reason shaped like a password, key or token included, CONFLICT, INVALID_PRINCIPAL for a principal the console may not change, FORBIDDEN, UNAVAILABLE). The detail also carries expiring_key, rules_origin (rules, recognized-seed or null: a seed bundle written as Cedar is read as rules by the policy page's own recogniser, while policy_document still carries its exact text), each recent row's target_label (the target's provider identifier) and administered, whether this reader may change the agent (spaces.administered_clause), so a page offers no change the server answers 404. The agent page renders one section at a time from ?section= (overview, connection, permissions, activity, advanced), checked in its own chunk and defaulting to Connection for an active agent with no request yet and to Overview otherwise. The pages are /agents and /agents/{id}; tests/test_console_api_agents.py, tests/test_browser_console_directory.py and console/src/components/directory/*.test.tsx hold the behaviour above.
/api/console/v1/people is the People page as JSON, every route a wrapper over the function the page in surfaces/people_pages.py calls (and the credential and scope helpers of admin_pages.py it shares), under an administrator's session and the same fence and bounds; an approver answers 403, a personal access token follows token_rules.py for this area and any other bearer credential is refused and audited, and a cross-tenant, agent or malformed id answers 404, as GET /people/{principal_id} and every change do for a person the administrator may not administer (require_person, since 0072). GET /people is the list of the people the reader may administer with its two filters (state: active, deactivated, all; kind: any, HUMAN_APPROVER, ADMIN), declared for the generated client and read under the pages' query allowlist; POST /people creates a labelled approver; GET /people/{principal_id} is the person with identity, credentials, scopes and the targets and actions the assign form offers, active sessions with their device labels, history (decisions and sign-ins for an approver, sign-ins and administrative events for an administrator, twenty each; a decision carries its approval_id and its execution_id, null outside the reader's visible spaces, and an event its subject_id, subject_kind (bundle, space, credential or principal) and, for a principal, its kind and number) and whether the person is the signed-in administrator. A list row and the person carry second_factor, read through admin_member_factors (0079) as the step-up reads a factor. The changes are POST /people/{principal_id}/label, .../credentials/issue (the show-once credential under no-store), .../credentials/{credential_id}/revoke, .../scopes/assign and .../scopes/unassign (a canonical target and a catalog action; a write on the space it lands in rather than on the person, so it is authorised exactly as /approvers/assign is and the person need only be a human of this tenant, answering the detail where the reader may administer the person and 204 with no body where not, as for an administrator who also sits in another space and assigns themselves from an approval), .../sessions/end (every live session of that person, one human_session.revoked event per row with reason ADMIN, never the acting administrator's own), .../deactivate (a reason; atomic across credentials and sessions, scopes kept) and .../reactivate. The functions' own refusals hold: an ADMIN target is given no console credential and cannot be deactivated (INVALID_PRINCIPAL), and a change already made answers CONFLICT. A label or a deactivation reason shaped like a password, key or token, or an email address whose domain is, answers 400 INVALID_BODY naming label, reason or email. A person and a list row carry replaced_by, the entry that took this one's place when the person rejoined through a teammate invitation (migration 0061_replaced_principals); such an entry is retired for good, the pages show it as "Rejoined as a new entry" with a link to the new one and no Reactivate, and .../reactivate answers PRINCIPAL_REPLACED (409). A list row carries email and number, and the person carries number beside sign_in.email: the person's counting number among the workspace's people (migration 0062_people_numbers), which starts at 1 in each workspace on a counter separate from agents', so a workspace has a User 1 and an Agent 1. The access, approvers and credentials payloads and a space's member rows carry the same number, with email wherever the console names a person by it; a space's member rows carry the email only to an administrator, the person People admits and shows it to already (the session identity's effective kind is ADMIN), and anyone else who can read the space gets the number alone, including a Viewer, Approver or Policy manager granted at Workspace scope, whose workspace-wide read access opens every space but is not administration. The console names a person by the label, else the email, else "User N", with "User N" beside the name where the id prefix used to be (the People list and the person's header, "Edit roles for ...", the approvers list and the assign form, the credentials list, a space's members, groups), and never as "unlabelled" (PersonName.tsx and principalName, handle and withHandle in format.ts, which name agents as "Agent N" the same way). The number is a display handle only: routes, links and every reference keep principal_id. The pages are /people and /people/{id}; tests/test_console_api_people.py, tests/test_browser_console_directory.py and console/src/components/directory/people.test.tsx hold the behaviour above.
/api/console/v1/spaces is the Spaces area: a tenant's spaces, which divide ownership and view inside one tenant while the tenant stays the one security boundary. A space is a column plus a membership check in the store functions and never a second fence, and no page, document or sentence may describe one as isolation. GET /spaces is the person's visible spaces — every space of the tenant for a tenant administrator, which is an active ADMIN principal with no membership at all, and their memberships for anyone else — with each one's name, detail, archived state, member count and the counts the switcher shows, filtered by ?q= (1 to 80 characters) and ?state=active|archived|all, cursor-paginated at fifty on (lower(name), space_id). POST /spaces takes {name, detail} from a tenant administrator, a name already taken case-insensitively answering CONFLICT. GET /spaces/{space_id} is one space with its members (principal, label, kind, role), its policy selection, its counts and the objects it offers tenant-wide; a space that is unknown, another tenant's or outside the reader's visible set answers NOT_FOUND alike. POST /spaces/{space_id}/rename, /archive, /unarchive, /members/assign and /members/unassign are the tenant administrator's five changes, each answering the space as it is afterwards: archiving the tenant's last unarchived space is refused CONFLICT, an archived space takes no new member (SPACE_ARCHIVED, the one code this area adds), the ADMIN role is refused for a HUMAN_APPROVER principal (INVALID_BODY naming role, because a space role narrows and never raises), and the assignment that would leave a tenant with no tenant administrator is refused CONFLICT; a name, or an archive or unarchive reason, shaped like a password, key or token answers INVALID_BODY naming name or reason. POST /api/console/v1/session/space takes {space_id} or {space_id: null} for "All spaces" and answers the fresh session view; it is the one place a space is named by a caller, and its argument is verified against membership inside the database by session_set_space. There is no ?space= anywhere, so editing a URL cannot ask for another space's rows. A space switch is deliberately not audited: a view change grants nothing and every read it enables is already covered by its own surface's events. GET /api/console/v1/tenant keeps the tenant-level facts and its active bundle and monitoring counts become the current space's; GET /api/console/v1/search answers within the current space and names each row's space when the reader can see more than one. tests/test_console_api_spaces.py, tests/test_spaces_isolation.py and tests/test_spaces.py hold the behaviour above.
The console's own spaces are the sidebar switcher and the /spaces page. The sidebar's tenant block is the switcher: a button, never a link, showing the space name over the tenant name, so the one security boundary is readable beside the division inside it; on a phone, where the sidebar is not on screen, the same control sits in the top bar and the popover names the tenant, and up to 480 px wide it shows only the space's initials and its chevron, keeping its full name as its label and clipping the chevron inside its own box on a phone too narrow for both beside a second workspace's chip, and the mode pill beside it only its dot, keeping the mode word as visually hidden text. It opens a focus-trapped listbox of the person's visible spaces with the current one marked, "All spaces" where their set holds more than one, a filter that asks GET /spaces?q= once the typing settles, "Show archived", and, for a tenant administrator, New space and Manage spaces. Choosing one posts POST /session/space and then resets the whole query cache: the generated client's keys carry no space, so an invalidate would leave one customer's rows rendered under another's name while the refetch ran. The first breadcrumb reads <space> · <tenant>, followed by the page's own ancestors as links, the command palette gains a Spaces group and names each record's space where the reader sees more than one, and /spaces is a route in the Team & access group carrying the list, one space's members table with assign and unassign, rename, archive and unarchive, the objects it offers tenant-wide and its counts. A single record named by its own identifier is authorised by membership and answered whatever the session's current space is: an approval link opens the approval, its answer names the space that owns it, and the shell follows it there and says so. The Activity drawer's "Open in Approvals" is that same link, /approvals/{approval_id}, so a record another of the person's spaces owns, opened in the drawer by its own id, leads to its approval in that space; it is offered only to someone who decides approvals in at least one space, because for anyone else the approval page holds nothing but a refusal. There is no ?space= anywhere. tests/test_browser_console_spaces.py, console/src/shell/switcher.test.tsx and console/src/components/spaces/spaces.test.tsx hold it.
/api/console/v1/credentials and /api/console/v1/approvers are the credentials and approvers pages as JSON, each route a wrapper over the function the page in surfaces/admin_pages.py calls, under an administrator's session and the same fence and bounds; an approver answers 403, a personal access token follows token_rules.py for this area and any other bearer credential is refused and audited. GET /credentials is the credentials of the people the reader may administer and of the agents in the spaces the reader may see, filtered by the space they are looking at as the Agents list is (a person belongs to no space and is in every view; a workspace administrator administers every person, and one narrowed to spaces only those whose every seat is in one of their ADMIN spaces), grouped by principal kind and principal, with each principal's label and live count, the principals the issue form offers, and the live total; POST /credentials/issue takes a principal (an id of this tenant, or new:AGENT or new:HUMAN_APPROVER to create one) and a lifetime and answers the show-once credential under no-store, the definer function refusing an ADMIN principal, and an agent of a space where an administrator narrowed to spaces holds no ADMIN membership or a person outside their spaces (INVALID_PRINCIPAL) and a new agent's label shaped like a password, key or token answering INVALID_BODY naming label; Each key row of both lists carries last_used_at, stamped by authentication at most once a minute through credential_used (0079) and shown only, and replaced, true when a live key names it through replaces. GET /credentials/expiring (token allowed, an approver 403) answers the used agent keys of active agents in the reader's visible spaces that expire within seven days and that no live key replaces, one per agent by its soonest expiry, at most five with more counting the rest; the agent detail's expiring_key applies the same rule to one agent, and Home and the agent's page show it as a notice. POST /credentials/{credential_id}/revoke revokes one and answers the list as it is afterwards, an administrator's, an already revoked credential or a credential of such an agent or person answering CONFLICT. GET /approvers is the active assignments by approver, canonical target and action, each with the approver's label and the target's provider identifiers, with the approvers, targets and catalog actions the assign form offers; POST /approvers/assign and POST /approvers/unassign take an approver, a target and an action, an action outside the catalog answering INVALID_BODY and a change already made answering CONFLICT. The pages are /credentials and /approvers; tests/test_console_api_access.py, tests/test_browser_console_directory.py and console/src/components/directory/access.test.tsx hold the behaviour above.
Decision coverage. Admission records, beside every approval it holds, how many distinct people scope_check would let decide it at that instant (approvals.reviewers_at_hold, counted by approval_routing.eligible_reviewers under the tenant fence), and two reads answer a workspace administrator from it and from the live view. GET /api/console/v1/approval-coverage (readApprovalCoverage) lists, for each non-archived Space by name (at most 50, truncated past that), its selection (policy none, rules or custom) and each action the active bundle can hold, with what holds it (rule:<id>, limit:<id> or policy), whether that can be read from the policy (known, false for a custom Cedar bundle with approval policies, never guessed), the quorum, how many people can decide the action on at least one target in the Space, and three lists over the last 30 days, each with its total and at most 20 rows, newest first: held_uncovered, targets held that fewer people can decide now than the action needs; requested_uncovered, targets requested and never held that would be short if they were; and thin, targets held whose eligible count equals the quorum. A row carries the canonical target id, its provider identifiers, the request count, the latest request and the eligible count, never a reviewer. The scan reads at most 5,000 (Space, action, target) groups, newest first, through the executions_created index, and says truncated when there were more. GET /api/console/v1/approval-coverage/unrouted (readUnroutedApprovals) answers, of the newest 200 open approvals, how many fewer people can decide now than they need (pending) and how many nobody can (pending_nobody), with the newest one's reference, and how many approvals held in the last seven days were recorded that way when they were held (recent), skipping approvals held before the record existed. Both require workspace-wide access.manage: an administrator narrowed to Spaces, an approver and a session without the permission answer 403 FORBIDDEN, an agent credential is refused and audited, and a personal access token may read them as it reads the approvers matrix. Each reads in its own REPEATABLE READ snapshot without the tenant fence, so neither waits behind a writer, and each re-checks its guard at the end. ApprovalItem.reviewers_at_hold carries the record to the same readers only, null to everyone else. The console shows the unrouted line on Home's needs card and above the Approvals chips (loaded lazily there, and never on an open approval), a badge on a Lapsed row the record says too few people could decide, a Decision coverage card at the top of /approvers whose fixes open the existing rule and assignment dialogs prefilled with the gap's target, and a line under each review rule of an active policy. The coverage read is never polled; the unrouted line rides the page's shared timer. Every count is a preview: no decision, resume or consume reads the record, and eligibility is checked again when a person decides and before execution. tests/test_approval_routing.py, tests/test_console_api_approval_routing.py and tests/test_browser_console_routing.py hold the behaviour above, with AR14 to AR23 in the acceptance inventory.
GET /api/console/v1/connection is core's own "Connect this agent" card: the gateway origin, the client recipes rendered from it, and, once a workspace has installed sample targets with an open incident, the sample development workstation's hint for the example request. It also names operation_ref, read in the same transaction: the operation the starter bundle names for the installed samples' active mapping of the isolation action, which the card and Getting started put in the request a person relays, execute with that operation_ref, the target's hostname and its incident as claimed_context.incident_id. It is null while no samples are installed, and while the mapping the starter names for them is not live, where the card names the target and its incident alone. It discloses nothing new: the starter bundle's own detail, GET /api/console/v1/policy/{bundle_id}, already lists each mapping's operation_ref. The view names one target, to a person, and never lists targets. This route's workstation is the sample development workstation's hint. When it is null, core names no workstation, and the card renders its edition's FixtureHint slot there instead, which is empty in core. The read is an administrator's, guarded exactly as any other console API route, and rechecks the identity and the session inside the same fenced transaction the read is taken in, the one GET /api/console/v1/samples reads its status in (samples.checked_status). tests/test_console_api_connection.py and tests/test_route_authority.py hold the behaviour above.
The monitoring area is an administrator's (kind="ADMIN" on every guard) and wraps the monitoring store's own functions (execbound.monitoring.store). GET /api/console/v1/monitor is snapshot: the collection connections (label, origin, team scope, health, the audit cursor, the last run and its outcome, the last sync, a retry-after, the probed capabilities; never the key or its fingerprint), the reporting sources with the coverage word the Connections page computes (Disabled, Expired, No heartbeat, Stale, Reporting; never a token), the discovered stories with their coverage label and the one unrevoked source each names, if any (one in a space the reader sees before one elsewhere, then, among those the reader sees, one whose token can report before an expired one and the latest to expire; one elsewhere by its identifier alone), with source_visible saying which and source_expired whether a source the reader sees has expired, false for one elsewhere, so nothing about a source in a space the reader does not see follows its token, because a story's gap is reassessed only when inventory is read and a source registered since sits beside a gap still open, and with connection_revoked whether the story's connection was revoked, so that no inventory pass reassesses its gap again, the discovered credentials as metadata, the recent observations with how each was obtained and the findings each raised, the findings joined to what each is about, the deliveries, each with its destination, and the intents not routed (a routed one is left out, since its deliveries say what happened), the workspace's findings destinations for every administrator by id, kind, host, rules and Spaces (a Space the reader cannot read counted in other_spaces, never named, and for a reader of every Space a configured id that is no Space of the workspace counted in unknown_spaces; never a URL, address or secret), the active detection profile's version, the count of open findings, counted in SQL so it is right beyond the 200 shown, each list bounded at 200 as the pages are, and synthetic_directory, read for the whole workspace rather than the Space the session looks at because the synthetic directory holds for every Space: present while one is active, for every reader, and addable while none is and no Space ever connected Microsoft Entra, said only to a reader of every Space and false to anyone else. Every list holds to the reader's space predicate: inventory and coverage through their connection's space, deliveries through their finding's; discovered stories with an open gap come first, the oldest gap first. Each connection carries overdue_since and reporting from one freshness rule (collection_overdue_since, collection_reporting): a connection is overdue a day after its last run of any outcome, or its registration (a connection the collector opens itself, such as a sealed one, at twice its own interval if that is sooner), or two days after its last inventory sync, whichever comes first, and reporting only while its last run completed recently, it has synced, and no capability it needs is forbidden; the setup's verifier calls the same rule. Each connection also carries its schedule: its kind, its credential_mode (key_file, an operator's key file on the collecting host; sealed, a Tines key pasted in the console and sealed for the collector; or platform_app, a Microsoft Entra directory whose administrator consented to the deployment's app; never the ciphertext, the collector key's id or the fingerprint), collect_interval_seconds and activity_interval_seconds with the kind's default filled in (Entra has no activity interval), a waiting collection request as collect_requested_at and collect_requested_by (a principal reference), collecting while a run holds the connection's lease (never the lease itself), and next_due_at, computed by the rule the scheduled collector reads (monitoring/due.py, due_at); a consented connection adds consented_at, consented_by (a principal reference) and consent_needs_update, true once a permission it had is refused, and still true after a later consent from the first probe after it that finds the permission refused. Each finding names what it is about: for an agent rule the directory agent with its class, sponsor state, number of sponsors and owner count (never the sponsors themselves), the connection's label and the source's label, beside the acknowledger as a principal reference. GET /api/console/v1/monitor/findings?state=open|acknowledged|all (open by default, any other value 400 INVALID_REQUEST, any other query key refused) answers the findings in that state, newest first and at most 200, with the open and acknowledged totals and the active profile; GET /api/console/v1/monitor/findings/{finding_id} answers one finding, and a malformed identifier, another tenant's, one in a space the session does not see and one that does not exist all answer the same 404. A personal access token may call both reads. GET /api/console/v1/monitor/agents is agents_snapshot, Scout's to-do list: the first 200 actors in its order, cut after ordering (what did something consequential outside ExecBound in the last thirty days, most first, then what has open paths, most first, then the rest, then by name), each with its counts, did and did_at_least, open_paths, accepted and accepted_until, its strongest current driver the session may see, drivers_more, and tines_driven, whether any current driver row it may see is a Tines tenant's, its source and synthetic, its app_ref and removal, the consequential actions, and the tenant line that states its own denominator, with assessed, built_in (no profile is set and the synthetic directory is assessed against the built-in sensitive actions, which consequential lists), the actors per_source and the unattributed directory actors, those with no current driver row the session may see, a Tines tenant's row seen only where the session sees its connection's Space as in the rows, and GET /api/console/v1/monitor/agents/{agent_id} is agent_detail (the identity, with the leading product token of the user agent its newest successful sign-in sent as user_agent_hint, never evidence; the grants with the actions the reviewed catalog explains, every reach row with its effective certainty, the findings and the observations attributed to it, how many grants could not be explained, its drivers, each with its evidence level, the platform's kind and name, a catalog entry's source and the day it was read, a join's story ids and whether they are "at least", a Tines tenant's count of credentials with no host restriction, and its first, last and gone moments, and driver_gaps, the platforms whose actors cannot be attributed by network and those whose feed is still pending; its source, synthetic, app_ref and removal, each reach row's standing acceptance with its expiry, its author and whether it is in force (accepted) and whether the path is open, its acceptances, every acceptance and withdrawal newest first, and assessed); an identifier that is not one, or an agent that is not this tenant's, answers 404 NOT_FOUND. POST /api/console/v1/monitor/agents/{agent_id}/accept (acceptMonitorReach, {"paths", "reason_code", "days"}) accepts one to 50 of the actor's open paths (live grant, role or inherited paths to an action the workspace calls consequential, with no acceptance in force) for one of five reason codes and 30, 90 (the default), 180 or 365 days, renewing one whose acceptance expired, and /withdraw (withdrawMonitorReach, one path of any kind an acceptance is recorded for, an observed path accept-reach accepted included) withdraws a standing acceptance; each answers the actor as the server now holds it, a path that is not open, a reason outside the list or a period outside the four answers 400 INVALID_BODY, an actor outside the session's Spaces 404 NOT_FOUND, and a withdrawal no acceptance stands for 409 CONFLICT. Both ask for a recent factor (MFA_REQUIRED) and refuse a personal access token. POST /api/console/v1/monitor/findings/{finding_id}/acknowledge takes {} or {"note": ...} and is acknowledge, answered with the fresh snapshot. It reads the finding under the same space predicate as the snapshot, so a finding in a space the caller does not see answers 404 exactly as one that does not exist, and each acknowledgement that changes the row appends monitor.finding_acknowledged naming the finding, its rule and the credential that acted, a personal access token's backing credential included, and the note when there is one. The note is optional, at most 500 characters, trimmed (only spaces is no note), and refused, never quoted, as 400 INVALID_BODY naming note when it is not printable text or is shaped like a secret; the finding carries it as acknowledgement_note, and no other surface, the assistant's finding tool included, reads it. An acknowledgement is final: the database refuses any change to one once made. Acknowledging an acknowledged finding without a note is the idempotent 200 and appends nothing; with a note it answers 409 CONFLICT and saves nothing. POST /api/console/v1/monitor/findings/acknowledge (acknowledgeFindings) takes {"finding_ids": [...], "note": ...}, one to 50 distinct canonical identifiers and one optional note for them all, and is acknowledge_many: it locks the findings the space predicate admits in identifier order, acknowledges each open one with the note and its own event, and answers {"results": [{"finding_id", "outcome"}]} in request order, where outcome is acknowledged, already_acknowledged (its note is left as it was) or unavailable (outside the spaces the caller sees, or no such finding, which are not told apart). A duplicate, more than 50, none, or an identifier that is not canonical answers 400 INVALID_BODY naming finding_ids before anything is read. Because an acknowledgement is final, this route is refused to personal access tokens (TOKEN_NOT_ALLOWED), which keep the single-finding route, and the console sends it only after an inline confirmation naming the count. Every platform-supplied string travels as data and the client renders it as text. The sidebar's Monitoring count is read from the tenant view an administrator's shell already holds, which is not polled: acknowledging a finding invalidates it and the window regaining focus refreshes it. The count is the open findings while there are any, else the quiet items (stale sources plus overdue connections, counted per space as stale and overdue), and the entry links to what it counts. The Findings view reads its own route and is not polled either: an acknowledgement and window focus bring it up to date. tests/test_console_api_monitoring.py and tests/test_browser_console_monitor.py hold the behaviour above; the pages are /monitor with ?view= naming the view and /monitor/agents/{id}. /monitor also reads state (open, acknowledged or all), open (one finding's canonical identifier), story (a story identifier matching the reporting-source pattern), focus (quiet) and connect (done or refused, which only says Microsoft sent the browser back; what the page says comes from the server, and the value is taken out of the address once read); each is checked against its list or pattern in the route and again in the page, and a value outside it is dropped.
Connecting monitoring from the console is the same area's. GET /api/console/v1/monitor/connect (readMonitorConnect) answers {"tines", "entra", "sources", "intake_url", "entra_display_name", "entra_client_id", "last_attempt", "placement"}: tines, entra and sources are each available or unconfigured, Tines available where the runtime configuration's scout.collector gives the collector's public key and Microsoft Entra where scout.entra names the deployment's platform app, whose display name and client id it then gives; sources is available exactly where Tines is, and intake_url is where a reporting story sends its events, the configured origin's /v1/monitor/events; last_attempt is the reader's own latest Microsoft Entra consent attempt if it ended within ten minutes, {"outcome": "consented" | "refused", "refused_after_consent", "connection_id"}, never another person's and never a code; placement is where a connection or source made now lands, {"space_id", "name", "archived", "administered"} for the session's Space, or null where the session sees several Spaces and has chosen none, so the console says before the step-up that an archived Space or one the administrator only reviews takes nothing. The session's Space is the session's, shared by every tab on its cookie, so once the session the console reads names another Space than placement does, the console reads the options again and places nothing until they answer. POST /api/console/v1/monitor/connections/tines (connectTines) takes {"label", "base_url", "team_id"?, "api_key"} and answers the new connection, 201: the web service seals the key to the collector's public key in an envelope bound to the workspace, the connection and the kind, stores the ciphertext beside the key's SHA-256 through admin_create_sealed_connection in the session's Space, asks for the first collection, and never returns or logs the key. Without the block it answers 409 CONNECT_UNAVAILABLE; a second connection in the Space CONNECTION_EXISTS; a full workspace CONNECTION_CAPACITY; no Space chosen among several 422 SPACE_REQUIRED; an archived Space SPACE_ARCHIVED; a malformed body, or a label or team id shaped like a secret, 400 INVALID_BODY naming the field and repeating nothing. POST .../connections/{connection_id}/key (replaceMonitorConnectionKey, {"api_key"}) replaces a sealed connection's key and asks for a collection (an operator's key-file connection answers CONFLICT); /collect (requestMonitorCollection, {}) asks the next scheduled pass to collect, a request already waiting being the one answered; /schedule (scheduleMonitorConnection, {"collect_interval_seconds"?, "activity_interval_seconds"?}, null for the kind's default) sets the intervals within the kind's range in any credential mode; /egress (recordMonitorEgress, {"addresses"}) records, on a Tines connection only, the addresses its tenant's HTTP requests leave from (at most eight single public addresses; [] clears them; one another platform's catalogued range holds, a repeat or anything but an address answers 400 INVALID_BODY naming addresses, and a directory connection 409 CONFLICT), which a directory's service principal sign-ins in the same Space are matched against for driver evidence (Scout); /revoke (revokeMonitorConnection, {}) stops collection and keeps every row. Each answers the connection as the snapshot carries it, a Tines connection with its egress_addresses, egress_recorded_at and egress_recorded_by and a directory connection with its sign_in_cursor; a connection outside the session's Spaces, or no such connection, answers 404 and a revoked one 409 CONFLICT. Connecting, replacing a key, recording egress addresses and revoking ask for a recent factor (MFA_REQUIRED) and refuse a personal access token; the options read, /collect and /schedule do neither. POST /api/console/v1/monitor/connections/synthetic (addSyntheticDirectory, {}) adds the workspace's synthetic directory in the session's Space and answers it with 201: a connection of kind fixture and mode synthetic, labelled "Synthetic directory", read in process from a fixed dataset (Scout); it holds for every Space, so an administrator a membership narrows to their Spaces answers 403 FORBIDDEN, a workspace that ever connected Microsoft Entra answers 409 DIRECTORY_CONNECTED, and a second one 409 CONNECTION_EXISTS. It holds no credential, so it asks for no factor, and a personal access token cannot add one; /revoke removes it. While it is present, /monitor/connections/entra/start answers 409 SYNTHETIC_DIRECTORY_PRESENT. Each change appends one informational event (monitor.connection_created, monitor.key_replaced, monitor.collection_requested, monitor.schedule_changed, monitor.egress_recorded, monitor.connection_revoked) naming the connection and the credential that acted, with the connection's Space on the row; monitor.egress_recorded carries how many addresses, never which. The gateway never calls Tines: collection runs in the collector, which alone holds the private key. tests/test_console_api_monitoring_connect.py holds the behaviour above. POST /api/console/v1/monitor/connections/entra/start (startEntraConnect) takes {"label"} for a new connection or {"connection_id"} to update a consented connection's consent, never both, records the attempt in the session's Space (or the connection's) and answers {"authorize_url"}, Microsoft's sign-in address, with no-store and the binding cookie (HttpOnly, Secure, SameSite=None, path /, ten minutes); the address carries the attempt's state, the one secret this API answers with, and the console follows it only when it begins https://login.microsoftonline.com/. Without scout.entra it answers 409 CONNECT_UNAVAILABLE; a full workspace CONNECTION_CAPACITY; a Space that already has a Microsoft Entra connection CONNECTION_EXISTS; a sixth live attempt or a twenty-first in a day 429 ATTEMPT_CAPACITY; an update for a connection that is not a consented one CONFLICT. It asks for a recent factor and refuses a personal access token. Microsoft's two callbacks are browser routes outside this API, served only where scout.entra is configured and answering 404 elsewhere: POST /connect/entra/signed-in takes Microsoft's form_post of the administrator's id_token, from Microsoft's own origin alone, verifies it, checks the attempt, the binding cookie and the directory, and answers a 303 to that directory's admin consent page; GET /connect/entra/consented takes Microsoft's redirect back, reading only the keys Microsoft documents for it, and creates the platform_app connection or records the update's consent, appending monitor.connection_created and monitor.consent_recorded. Every refusal ends the attempt as refused with its code and step, creates nothing, logs monitor.connect_refused with the code alone, and, like a success, answers a page of this site's own that moves the browser to /monitor?view=connections&connect=done or connect=refused, so the session's SameSite=Strict cookie travels with the next request. tests/test_monitor_consent.py holds the flow.
Reporting sources are the same area's. POST /api/console/v1/monitor/sources (createMonitorSource) takes {"label", "story_id", "lifetime_days", "heartbeat_seconds"}, with a lifetime of 1, 7 or 30 days and a heartbeat of 300, 900 or 3,600 seconds (a source registered every 60 seconds before keeps that interval, and is read, rotated and revoked as any other), registers the story in the session's Space and answers MintedSource, 201: {"source_id", "story_id", "label", "token", "expires_at", "heartbeat_seconds", "previous_valid_until", "intake_url"}, with Cache-Control: no-store set by the route itself. The token is in that answer and the rotation's and in no other; the database keeps its SHA-256. GET .../sources/{source_id} (readMonitorSource) answers the source as the snapshot carries it, revoked or not, never its token; each source, there and in the snapshot, carries administered, whether this administrator may rotate or revoke it, which is false in a Space they only review, so the console offers neither there. POST .../sources/{source_id}/rotate (rotateMonitorSource, {"lifetime_days", "grace_minutes"}, a grace of 0, 10, 30 or 60 minutes) answers the replacement as MintedSource, no-store, with previous_valid_until when the previous token is still accepted; /revoke (revokeMonitorSource, {}) answers the revoked source, read in the transaction that revoked it, so nothing after the commit can fail the answer to a revocation that took effect. Minting and rotation answer 409 CONNECT_UNAVAILABLE without scout.collector; reading and revoking are served everywhere, so a source minted earlier can always be revoked. Minting refuses a story that already has an unrevoked source in the Space that can still take its events, expired or not, with 409 SOURCE_EXISTS (one that has kept its 10,000 events, which rotation does not reset, refuses none, so its story is added again while it stands and it is revoked afterwards), which is its code and sentence and names no source, so a mint repeated after an answer that was lost adds no second source, and the one it made is among the snapshot's sources; a full workspace (100 sources, revoked ones included) with 409 SOURCE_CAPACITY; no Space chosen among several with 422 SPACE_REQUIRED; an archived Space with SPACE_ARCHIVED; a Space the administrator does not administer with 403 FORBIDDEN; and a malformed body, a label shaped like a secret or not printable, or a story ID shaped like a secret with 400 INVALID_BODY naming the field and repeating nothing. A source outside the session's Spaces, or none, answers 404; rotating or revoking a revoked one 409 CONFLICT. Minting, rotating and revoking ask for a recent factor (MFA_REQUIRED) and refuse a personal access token; reading does neither. The source records who minted, rotated and revoked it (created_by, rotated_by, revoked_by) and none of it reaches the audit chain. tests/test_console_api_monitoring_sources.py holds the behaviour above.
Getting started is core's. /welcome is core's Getting started page, and stands on core's own reads: its sample targets come from GET /api/console/v1/samples below, an administrator's route that a personal access token may also call. POST /api/console/v1/samples, /samples/refresh and /samples/remove are core's own too, and so is POST /api/console/v1/welcome/example (the safe example). The hosted service's Invitations area and its additions to Getting started are documented with the hosted service.
Sample targets are core's and an administrator's. GET /api/console/v1/samples (readSamples) answers their status, an administrator's read that a personal access token may also make, taken in one fenced transaction with the identity and the session rechecked, as GET /api/console/v1/connection reads. The status is absent, present or removable; whether they are offered (this deployment holds a sample key and no configured connector for the workspace, and the workspace has no selected policy and no active connection) and available to this workspace at all; whether demo_available, the demo configuration below being handed out now, and whether demo_needs_refresh, a refresh of the sample facts being all that stands in its way, never both; the starter bundle's identity; the three targets with hostname, role, environment, criticality, open incident and what the starter rules read on each; when their facts were recorded and when they go stale; and the first request an agent made on one of them. With samples present, GET /api/console/v1/connection's workstation names the sample development workstation and its incident. POST /api/console/v1/samples takes {} and installs them idempotently: one transaction under the tenant fence writes the registry rows through the samples_install definer function and creates, validates and activates the starter bundle through the policy store, and a second call while they are present answers what exists and changes nothing. POST /api/console/v1/samples/remove takes {}, deactivates the sample connection, mapping, targets and review scopes, deselects and retires the starter bundle only while a space still selects it unchanged, deletes nothing and answers the status, which is also its answer when there are no samples. POST /api/console/v1/samples/refresh (refreshSamples) takes {} and re-creates the samples: in one transaction under the tenant fence it removes them as /samples/remove does and installs them as /samples does. The three targets come back with the same hostnames and provider ids but new canonical ids, resources and incidents, and facts recorded now; the old starter bundle is deselected and retired and a new one created, validated and activated; review of the samples returns to the refreshing administrator alone, because the removal switches off every reviewer's scope on the old targets and the install grants its actor a scope on each new one; the first request is counted afresh; and the audit chain records the removal's three events and the install's five, each naming the administrator and their credential, with samples.refreshed in the process log. Requests already made keep their rows and stay in Activity. Before anything is removed it checks, as the definer functions do, that the caller administers the whole workspace rather than a space, and it is refused while a bundle other than the current starter that is not retired names the sample account, which switching that account off would break, or while a space other than the default one selects the starter, which the refresh would leave with no policy; a refusal from either step changes nothing, since a refused install rolls the removal back with it. It answers the status afterwards, and a workspace with no samples is answered its status with nothing written and nothing logged. GET /api/console/v1/samples/demo-configuration (downloadSampleDemoConfiguration) is that status in the shape the client package's execbound-demo reads: an attachment named sandbox.json under Cache-Control: no-store holding "workspace": "samples", the console's origin, the tenant id, providers naming the origin of the endpoint mock that serves the samples as endpoint (left out when that origin is a loopback address, since the demo's boundary beat sends the agent's key to it and nothing the gateway holds says whether the person's machine reaches the mock there: the Community bundle publishes the gateway's port alone and keeps its mocks on loopback inside its own network, so on the person's machine that address is whatever listens there), and targets naming the allowed, denied and held samples as workstation, domain_controller and production, each by hostname with its open incident, beside an empty pool. It holds no credential; the person adds a credentials object naming one agent's API key file as agent_a, the only agent both beats send as. It is read as GET /samples reads, passes the export's secret check before it leaves (a failure there is answered 503 UNAVAILABLE and logged with no detail), and answers 404 NOT_FOUND whenever the demo could not run on the samples (samples.demo_targets, which demo_available reads too): while there are none, while this deployment does not serve them (available false: it holds no sample key or holds a configured connector for the workspace, so no sample connection is derived and every request would be refused), while the starter is not the policy in force (a policy a person activated is selected in the default space in its place, which decides the requests as that person wrote it and holds off a refresh while it names the samples, or the workspace observes rather than enforces), while the workspace's emergency stop is active, under which admission refuses every new request with 423 TENANT_STOPPED before it reads a fact and which only an administrator's release ends, while one of the three has no open incident, while the operation the starter names for them is not endpoint.isolate, while a request on the samples is unresolved, which may yet change a target, once their facts have gone stale by the runtime's clock (from stale_at, when admission refuses every request on them before any decision with 503 EXECUTION_UNAVAILABLE), or once a request since their facts were recorded has changed one of the three at the provider (executed, or dispatched and not settled, or settled on somebody's word alone), as guided setup's own requests do when the person's agent isolates the sample workstation and the approved production one, since the demo's isolation of it would then end STALE_AUTHORIZATION. Those last two cases alone are ones a refresh ends, and demo_needs_refresh says so. A download link carries no workspace header, so an account session names its generation as the generation query key, as the report's CSV does, and another session's generation is refused 409 WORKSPACE_CHANGED; it takes no other query key. The Sample targets card links it only while demo_available is true and the facts are still current by the clock it judges their staleness by, so it never offers a download the route would refuse, even with the page left open past stale_at, and while demo_needs_refresh is true, or the facts have gone stale since that read, says in its place that the configuration is offered while the sample facts are current and record the targets as they are, and to refresh the sample facts to download it. On that file the demo runs boundary and decide with unmarked requests and refuses budget, as the client's guide describes. The three changes take the session, CSRF and Origin checks every mutation takes, refuse anyone but an administrator with FORBIDDEN, and refuse with SAMPLES_NOT_OFFERED (a policy or a connection is already there, or, for a refresh, one of the two policies above depends on the samples), SAMPLES_BUSY (a request on the sample account is still ATTEMPTED, PENDING_APPROVAL, RESERVED, DISPATCH_COMMITTED or INDETERMINATE, which includes a held request whose approval lapsed, until something ends it, as a scheduled recovery run does) or SAMPLES_UNAVAILABLE (the deployment holds no sample key, or holds a configured connector for the workspace). A personal access token is refused POST /samples and /samples/refresh, which activate the starter bundle, with TOKEN_NOT_ALLOWED, and may read the status, download the demo configuration and remove the samples. Records a sample account served carry target.sample, and the starter bundle sample in the policy list and detail, which the console shows as a Sample badge; an approver sees them there, and is refused all five sample routes with FORBIDDEN. Getting started shows the card in its first step. With samples installed the card says when their facts were recorded and that from stale_at a request on them is refused before any decision, the agent told only that execution is unavailable; it shows a Stale badge once the server's clock, read again every half minute, reaches stale_at, and offers Refresh sample facts beside Remove sample targets, with what a refresh re-creates said beside them, including that the agent then needs a new request with a new reference. A refused refresh is worded as one that changed nothing, and a SAMPLES_NOT_OFFERED refusal adds the way past a policy made from the starter: Set your controls' copy of the active policy and its templates both name the samples, so such a policy stops every refresh until it is retired in Policies, and where it is the active policy the starter has to be activated again first, since a selected bundle cannot be retired and the refresh's install refuses any other active policy. Both buttons are held while a sample request waits for approval or provider evidence and while either change is on its way, and after a change the card reads the samples and the connection again rather than showing the change's own answer. Verify protection says when the facts have gone stale, with the same way past a policy made from the starter, and shows the first sample request, or, until there is one, the development workstation's request to relay, and the request to relay for the domain controller and the production workstation: execute with the operation_ref GET /api/console/v1/connection names, the target's hostname and its incident as claimed_context.incident_id, or the target and its incident alone until that operation has arrived or where it is null. The domain controller's line says that an agent is told a denial's reference and never its cause, apart from another request holding the target or a shared limit being spent, and that searching the console for the reference finds the reason. The production workstation's line states the workspace's approval deadline, GET /api/console/v1/tenant's deadline.seconds (the session's approval_deadline_seconds reads the default where the deadline is off), and, while the facts are current, that a request never waits past stale_at. It links the held request's approval at its own address: the newest on that target made since the facts were recorded, in GET /api/console/v1/approvals's pending list, or else a lapsed one in its lapsed list that nothing has ended yet, with when it lapsed, that the agent that made it ends it by sending the same request again, which admission answers STALE_AUTHORIZATION, after which a new request with a new reference waits for a new approval while the facts are current, and that otherwise a scheduled recovery run ends it. The tenant and the approval lists are reads an administrator already has, and the pending list holds only approvals within the reader's own review scopes, an administrator's included, while an administrator's lapsed list holds every lapsed approval in the spaces they can see; both hold only the space the reader is looking at when a space is chosen (records()' space filter; a session starts on All spaces). So the held request's link appears only to an administrator with a scope on the target, by default the administrator who added or last refreshed the samples, and the lapsed one to any administrator who is not looking at a space other than the default one, where the samples are. Under the lines Verify protection says that each new request needs a new reference from the agent and that it should reuse one only to send the same request again: the idempotency key is derived from the operation, the target and the reference, and the request's hash leaves claimed_context out, so a reference already recorded on a target brings back the request recorded under it, whatever incident it names, which after a refresh is the old answer. An agent's example request names the sample development workstation the same way. tests/test_samples.py, tests/test_console_api_samples.py, tests/test_console_api_connection.py, tests/test_console_api_tokens.py, tests/test_demo_command.py and tests/test_browser_console_samples.py hold the behaviour above, the last in real Chromium against a gateway that loads no plugin, with the console's WelcomePage.test.tsx for the card, SampleChecks.test.tsx for Verify protection's lines and RequestToRelay.test.tsx and agents.test.tsx for the request to relay.
Setup progress. GET /api/console/v1/setup (readSetup) answers what is already set up in the session's Space, each step from the record that satisfies it at request time (getting_started.setup_state), in a fixed order: policy, a bundle selected in the Space; agent_key, an active agent of the Space holding a live API key (a dormant or deactivated agent does not count); first_request, the Space's earliest execution, with its EB- reference; reviewer, someone in effective_approval_scopes for the Space whose action a mapping the selected bundle names serves (the view already holds each scope to approvals.decide and unions the approval rules' groups); and first_decision, the earliest decided approval in the Space, named by its execution and reference. Each step is done with its record's identifier and time, or todo. The policy, the key and the reviewer are current state, so a revoked or expired key, a deselected policy or a reviewer who can no longer decide un-ticks its step; the first request and the first decision are the earliest record and stay done. A selected bundle that can hold nothing for a person (an empty approval policy and no limit whose on_exceed is REQUIRE_APPROVAL) makes reviewer and first_decision not_applicable, uncounted, unless a decided approval is already on record. While the workspace observes, a first_decision still owed is needs_enforcing: counted in required, never in done, and never next; and observed counts this Space's OBSERVED executions of the last seven days by their would-be effect. Beside the steps it says whether the Space has a registered connected account with an active mapping (never counted), whether installing the sample targets could tick anything here (samples_here, only in the default Space and only for a workspace-wide reader) and the default Space's id for a workspace-wide reader. A workspace-wide reader on "All spaces" in a workspace with several answers choose_space with no steps. The read is an administrator's: the ADMIN kind at the guard, then one fenced transaction checking the identity as an administrator's, the session, the Space and the identity again; the Space is the session's write Space held to the reader's visibility and to the ADMIN role in it, so an administrator seated there as a reviewer is refused FORBIDDEN; a personal access token may read it, in the Space it names, and a Space its owner cannot see answers 422 SPACE_REQUIRED. It writes nothing, and names no target, hostname or label. tests/test_setup_progress.py and tests/test_console_api_setup.py hold it on real PostgreSQL, with the Space boundaries mutation-checked.
Getting started's progress. The step is in the address, /welcome?step=example|connect|verify|controls, checked against those four in the route's component (the route's validateSearch passes the search through, as Monitoring's does, so the check stays out of every page's first load); Back and Next push, so a reload and the browser's Back keep it. With no step named, the page reads the setup and the samples together and opens, once both answer, on Try an example where that is the only path to a decision here (no connection, samples offered, this the default Space), else on the first of Connect an agent, Verify protection and Set your controls left to do, written into the address as a replacement; without the setup read it opens on the example as before. A done step shows a tick, and inside it a note with its time and a link to its record. Where the Space has no connection, Connect an agent says requests are refused until it has a connection and a policy, and offers the samples where they can tick something here, says elsewhere that they install into the default Space, and otherwise gives the operator's commands for this workspace and Space (where operatorCommands is set) or names the service operator with the deployment's support address; Home's next step and the Policies page's hint say the same from the same reads. Where watchFirst is set and no request has arrived, a secondary line under Connect an agent and on Home offers connecting a monitoring source, /monitor?setup=tines, worded as visibility, never protection. Set your controls adds an optional Pilot in Observe only card, which says a policy is still required, that the mode is the whole workspace's and that the samples' approval loop needs Enforcing, and opens the reasoned, audited mode dialog with its step-up; where watchFirst is not set its Discover card names the service operator instead of linking Monitoring. Each step's caveats sit one disclosure away. For a session with access.manage, Set your controls' "Choose who can review" card also reads the Approval rules page's decision coverage, GET /api/console/v1/approval-coverage, and lists by the catalog's words, never by a target, each action this Space's active policy can hold that nobody can decide; "Add a reviewer" and, where the reader is among the approvers the assignment dialog offers, "Assign me" open that page's assignment dialog prefilled, which writes nothing until it is confirmed, and a custom policy's action reads "Coverage unknown for this policy" with no fix; a session without access.manage asks nothing, and a refused read shows nothing more (ReviewCoverage.test.tsx). The sidebar's Getting started card reads the same setup read, loaded on demand: "3 of 5 · Next: Choose a reviewer", or "Next: Connect a provider" while the Space has no connection, with a bar; it is the plain footer row while loading, on a failed read, on choose_space, once every required step is done, or where the person hid it in this browser (a per-workspace local preference, shown again from Getting started or Help). The sidebar's navigation is the only part that scrolls, so the footer and Sign out stay in view; a window under 640 px tall scrolls the sidebar as one column instead. Nothing polls: every console change that can complete a step invalidates the read. Monitoring's ?setup=tines|entra_agent_id opens its setup dialog on Connections, and closing it takes setup out of the address. On Connect this agent, a listening panel asks for the agent again on the console's shared timer while the agent is active, holds a live key and has made no request, never while the tab is hidden, and stops ten minutes of wall-clock time after it started, with Resume; it names no cause, and on arrival shows the request's state, reference and a link to its record. GET /api/console/v1/agents/{row_id}'s recent rows carry request_ref, and the agents list's rows last_execution_id and last_request_ref for the newest request in view. tests/test_browser_console_setup.py holds the fold, the steps and the pages in Chromium, with the Vitest cases under console/src/components/welcome/.
The Tines stories. Choosing "Tines story" as an agent's host on Connect this agent offers the two committed stories as downloads, the Execute story and the Gate story, with the steps the integration's README gives for each in its order: the credential and the resources the story reads (the gateway's origin from GET /api/console/v1/connection, the approval wait from GET /api/console/v1/tenant's deadline), the team to import into, opening Entry to assign its webhook path and secret or enabling Send to Story with its exits, and the test event. Both are Advisory for Tines. The console build copies integrations/tines/execbound-governed-action.json and execbound-gate.json unchanged into stories/tines/, served from /console/stories/tines/ like the build's other unhashed files and revalidated by ETag, with integrations/LICENSE beside them, which the wheel carries and scripts/check_wheel_licences.py holds byte for byte and which no browser is served; the build fails without any of the three. No story is rendered or templated, and the committed files carry no origin, token or tenant id (tests/test_tines_story.py, tests/test_tines_gate_story.py, tests/test_console_serving.py, tests/test_wheel_licences.py).
The console's Home is / and reads GET /api/console/v1/overview and GET /api/console/v1/executions?limit=8, both every ten seconds while the tab is visible, on the one timer the shell's counts share. It shows the figures for the chosen range (24 hours, 7 days or 30 days, kept in the URL as ?range=): decisions with a sparkline and the comparison with the range before, allowed, denied, stale, held for a person and unsettled; the decisions chart by outcome, an SVG whose description is a summary — the totals by outcome including stale, where in the range the activity is and the step — rather than an enumeration of columns most of which are empty. Every outcome the headline counts is in the tiles and in the legend, so the headline, the tiles and the legend are one arithmetic and a percentage is taken against what the legend shows. A comparison against a window that held nothing is not drawn: the tile says it is the first period of data instead. The recent decisions carry the day each run of rows belongs to, so a list that crosses midnight is not read as a sorting fault. "Needs you", which for an approver lists the pending approvals in their scope and for an administrator the approvals pending, the dispatches awaiting provider evidence with a link to the reconciliation queue, and monitoring sources quiet or findings open; and the eight most recent decisions with word-first badges, filtered by a row of chips. A row opens the record in the detail drawer and the URL keeps it as ?open=<execution id>, so a link, a reload and the back button restore it, and closing it returns the focus to that row even after a poll drew the list again; a mouse resting on a row, or the keyboard on its button, reads the record first, so the drawer opens on what it read within five seconds; the drawer reads GET /api/console/v1/executions/{id} and shows why the record was decided (each reason with the rule it names and its sentence), the facts used with their source and age, the arguments, the timeline, the approval with a link to Approvals, the shared limits that apply and the checkpoint or control-point sentences, every string as text. An approver's Home is the scoped version: the figures, the rows and the drawer hold only what their active scopes admit, and a record outside them answers "This record could not load." tests/test_browser_console_activity.py holds the page in Chromium and console/src/routes/-home.test.tsx its components. tests/soak_console_home.py leaves Home open while its polls bring a renamed agent and newly admitted requests, holds the JavaScript heap to less than twenty per cent growth since a fifth of the way in while counting the overview answers that arrived and checking that the recent decisions and the Decisions figure were drawn again, then hides the tab and holds it to no request for sixty-five seconds, longer than any timer Home holds. It is run deliberately and never by CI, because a soak takes minutes on every run: pytest collects only test_*.py by itself, so the file runs only when it is named (EXECBOUND_BOARD_SOAK_MINUTES=10 uv run pytest -s tests/soak_console_home.py).
Home also shows an "Effect unknown" tile beside Unsettled, both marked "Open now"; the Held tile counts only what a person can still decide and, while approvals lapsed before a decision await the sweep, links to exactly that list in Activity; a workspace with no decision says "No decisions yet" and one with no earlier window "No earlier 7 days to compare", once; an administrator's card says "Nothing yet. No request has reached a decision." until one has; and the findings row opens /monitor?view=findings. Under the chart, "What ExecBound did" reads GET /api/console/v1/report once and again on focus, never on a timer, and lists what was refused, held and settled for requests made since the window's start, each number a link to the Activity list it counts, with observe mode's counts apart and the line "Counts of what ExecBound recorded. A refusal is not proof that harm was prevented." /report is the same list as a printable page with the CSV, reached from the card. A denial's drawer links each rule its reason names to that rule on the rules page, /policy/{bundle_id}/rules?rule=, which marks it, where the reader may open that policy.
The console's Activity page is /activity and reads GET /api/console/v1/executions every ten seconds while the tab is visible, with the filters, the cursor and the open record all in the URL (?state=, ?since=, ?action=, ?agent=, ?target=, ?cursor=, ?open=), so a link, a reload and the back button restore them. Chips choose the state and the window; an action, an agent or a target a link set is a pinned chip that removes itself; the table (shared with Home) has a density setting remembered per browser, cursor pagination at fifty, and j and k along the rows from anywhere on the page. A change of filter, of page or of the open record keeps the keyboard's focus where it is, and the live region says once what a changed filter found ("3 actions match", "No actions match these filters", or "More than 50 actions match; showing the newest 50"), never on a poll; closing the drawer returns the focus to the row it was opened from, also where it was opened from an address. The drawer's own Explain sits below the record where the deployment has an assistant, and there is no other. ?state=UNCERTAIN is the reconciliation queue: for an administrator each row the server offers a settlement for carries Reconcile, which opens the record and the dialog for the kind the server named (provider evidence with an optional cancellation, the executor's signed record, or a correction), each with a required reason; the dialog posts to POST /api/console/v1/executions/{id}/reconcile with the continuation the detail carried, the button waits for the server's answer, a refusal is shown as the copy of its typed code, and a toast says what was settled or that the provider's journal held no terminal evidence. An approver is offered no settlement, in the rows or the drawer. tests/test_browser_console_activity.py holds the page in Chromium, including axe and the policy check with the drawer and the dialog open in both schemes at 1280 and 390 px, and console/src/routes/-activity.test.tsx its components.
The console's command palette opens with Ctrl or Cmd K, the sidebar's search button or the navigation sheet's Search row, and is named "Search or ask" where the deployment has an assistant and "Search" everywhere else. It reads GET /api/console/v1/search?q= once the typing settles (150 ms, at most eighty characters), so a keystroke is not a request. Its Records group lists what the search endpoint answers in the person's scope: a target by hostname or provider id, a request or an approval by the start of its id with its state as a word-first badge, for an administrator, an agent by label, by its number (agent 3, Agent #3) or by the start of its id, inside the space view, named "Agent 3" or its label with the handle beside it; and, for an administrator who holds access.manage, a person by label, number (user 3) or the start of their id, across the workspace and never by email; a request or an approval opens its record in the Activity drawer (/activity?open=), a target or an agent opens Activity narrowed to it (?target= or ?agent=), a person opens their page (/people/{id}), and each of those three also adds a "Go to" entry saying so, beside the pages whose names match. An empty answer says "No record matches in your scope."; in a space, where the person can also look at All spaces, it says "No record matches in POST /session/space) and nothing more: the shell is drawn again and the palette comes back with the same words, searching through the established visibility path, with no scope flag on the search and no ?space=. With nothing typed, the palette names what the search accepts for this person, and lists first what this person recently opened in this browser (Recent): up to eight pages and records, kept in localStorage as a kind and an identifier only, under the tenant's and the principal's ids, read back strictly and discarded whole if anything in it is not what the console writes; a page is shown by its label while the navigation still offers it, a record only once the scoped search finds it again by its id, so one whose access was revoked or which belongs to another space is absent; an approval is kept as its execution, because the search finds an execution through the reader's spaces. "Clear recent" empties it. A Commands group follows the edition's actions: the emergency stop's dialog where the top bar offers it, Add agent, Add user, New space and Issue API key where the page and its button are offered to this person, the keyboard list, "Copy link to this page" (the router's address for the page, or an approval's validated link) and Clear recent. A command only opens what a button would open: a dialog opened from the palette is the page's own, through a five-second, in-memory, closed-name command bus (console/src/lib/commands.ts), and its step-up, its checks and the server's answer are its own. A page is found by its label, by the words people search for it by (console/src/components/ui/aliases.ts, such as "people" for Users or "tenant" for Workspace settings) and by its address's own segment, and the word that matched is shown beside the label. An edition's links, paletteLinks, form an "Actions" group before "Go to", each offered only where the session's features hold its own feature, and each leaves the console for its server page; core's edition has none. The Ask group carries the typed words to the Ask page. Every label and detail is the server's, rendered as text, and a destination is built by the router from the record's identifier. console/src/components/ui/CommandPalette.test.tsx holds the palette's keyboard and its records, console/src/components/ui/palette-commands.test.tsx its Recent, commands, hint and all-spaces retry, console/src/lib/recents.test.tsx and console/src/lib/commands.test.tsx the stores behind them, and tests/test_browser_console_activity.py and tests/test_browser_console_shell.py the palette in Chromium.
The console's shell names every page and is the same around all of them. A page declares its name once (console/src/shell/page-name.ts), and the top bar's breadcrumb and the document title both read it: the breadcrumb is the workspace (and space), then the page's ancestors as links built by the router from identifiers, then the page itself as text, and from 768 to 1099 px the earlier ancestors wait behind one "…" disclosure that opens them as links, so the emergency stop stays on screen. The tab's title is the page's own words, its section, the space, the workspace and "ExecBound", most specific first, after the count of approvals waiting for this person where there are any ((2) Approvals · Acme SOC · ExecBound); its words come only from constructors that accept a request reference, a principal's number, the first eight characters of an id or one of the console's own nouns, so a hostname, a target, an agent's label or a provider's display name is a type error, not a title. The count is the pill's own query on the page's one timer, which stops while the tab is hidden, and a rise is announced politely once, never as a new space's count. The bell carries a dot and counts in its name what waits on this person, their assigned approvals and, for an administrator, open findings, and its panel says first what is waiting and why it is theirs, each row a link (an administrator also sees the approvals held for other approvers and the dispatches awaiting provider evidence, read once while the panel is open), then the sign-in security log. The keyboard: Ctrl or Cmd K for search, ? for the keyboard list, g then a letter for a page this person can see, and j and k along a list's rows; the single-key shortcuts can be turned off in this browser from My account or the keyboard list, and a decision, a stop or a settlement is never on the keyboard. Below 768 px, Menu opens a navigation sheet with the sidebar's own grouped list and pills, the footer entries, an edition's actions, Ask where there is one, and Sign out; its Close button takes the focus, so no soft keyboard rises. A change of the query alone keeps the focus where it is; a change of page moves it to the heading. Help links the deployment's documentation where it names one, opens the keyboard list, shows the version and lists the console's words. A page that fails other than by a typed refusal is reported once to POST /api/console/v1/client-errors with its route's pattern, the release, a reduced error name and a SHA-256 of the component stack, at most three a page load, and shows a reference, the hash's first eight characters, with a mail link to support where the deployment names an address; a typed FORBIDDEN, NOT_FOUND or UNAVAILABLE says what it is and reports nothing, and a console served without HTTPS, which cannot hash, sends nothing and shows no reference. A newer release, once noted, loads on the next change of page, never while a change is being sent, and the Live board loads it after a minute with no input; the banner's Reload waits for a change in flight and asks once over an open dialog. The plain frame stays until the session is known. console/src/shell/*.test.tsx, console/src/lib/client-errors.test.ts, console/src/lib/frame.test.tsx and tests/test_browser_console_shell.py hold the behaviour above.
/api/console/v1/policy is the Policy pages as JSON, every route a wrapper over the store function surfaces/policy_pages.py calls today — list_bundles, read_bundle, create_bundle, validate_bundle, activate_bundle, retire_bundle and preview_bundle — each of which rechecks the current ADMIN inside its own transaction; an approver answers 403, a personal access token follows token_rules.py for this area and any other bearer credential is refused and audited, and a bundle that is unknown, malformed or another tenant's answers 404 alike, because the store's read is fenced by tenant and the module does not distinguish the two. GET /policy lists every bundle with its lifecycle state, newest change first, and names the active one. GET /policy/{bundle_id} is the bundle: its mappings, its limits each with the use admission would count against it now, its quorum rules with the rule behind each obligation when the bundle carries a ruleset, its context settings, its rules in sentence form, and the compiled Cedar read-only. A limit carries a figure only where its scope has one tenant-wide answer (tenant, group); a principal, account or selector rule is counted separately per agent, per account or per value, so it carries none and says so, and the count itself is agents_pages.limit_usage, the one statement the Agents page's meters already read. POST /policy creates a draft from pasted JSON ({"source": "document", "document": ...}) or as a copy of the active bundle ({"source": "active"}), which is the same path: the active content is read back through the page's own parse_document with a fresh bundle_id, so a copy carries the ruleset and compiles to the same components under a different digest. POST /policy/{bundle_id}/validate, /activate and /retire each take {"confirm": true} and answer the bundle as it is afterwards; POST /policy/{bundle_id}/preview takes a window from a closed set (1h, 24h, 7d, 30d) and answers the baseline and candidate counts and the changed decisions, each naming the rule ids behind the candidate's obligations rather than the engine's positional identifiers, and available: false where the history could not be exported or verified, as the page's note says rather than showing zeroes. Replay executes no provider and changes no live budget, approval or execution state. A store refusal maps to a typed code (CONFLICT for a state the lifecycle does not allow and for a copy with no active bundle, INVALID_BODY naming the document's field locations, the location of text shaped like a password, key or token among them, which the store refuses as INVALID_TEXT before a draft is written, one code added — INVALID_BUNDLE — for content that cannot be used as it stands, carrying only the component or field the store named). tests/test_console_api_policy.py holds the behaviour above, including that a draft's preview equals preview_bundle for the same fixtures and that an unvalidated draft cannot be activated.
Since 8 October 2026 the area also answers these. POST /policy/{bundle_id}/activate takes {"confirm": true, "reason": ...}, a reason of 1 to 500 characters that the store screens and records as the reason key of policy.bundle_activated (INVALID_REASON reaches the console as INVALID_BODY naming reason); GET /policy gives each row the latest visible activation's note, and GET /policy/{bundle_id} its activation (when, by whom, the reason), read through the partial index audit_events_policy_activation with the Space filter every other read here takes. POST /policy/{bundle_id}/preview answers anyone holding policy.manage over the bundle: policy/preview.py preview_scope makes the check the route used to make, workspace.manage with no Space and any_scope left false, and anyone else, a narrowed administrator included, gets only the decisions recorded in Spaces where they hold policy.manage; the answer's scope says which (workspace or spaces). GET /policy/{bundle_id}/compare (comparePolicyBundle) compares the bundle with the one active in the reader's current Space as the rules read, through policy/compare.py, and answers each rule, budget, action, mapping, context and signal change with its direction (wider, narrower, unclear, none) and the counts, or why_not (no-space, no-active, is-active, cedar); it replays nothing. GET /policy/{bundle_id}/ruleset (readPolicyRulesFile) answers the stored ruleset as the file ruleset-compile reads, without budgets counted for a group, account or Space and without the context, and CONFLICT for a bundle with no stored ruleset; BundleDetail.rules_file says before the click what it leaves out. POST /policy takes {"source": "ruleset", "ruleset": ...}, parsed by rulesets.parse_ruleset_document (512 KB in bytes, no repeated key), refusing a qualified budget or a context at its location, resolving every action against the Space's own or offered mappings (INVALID_BODY where none serves it, SIGNAL_MAPPING_ONLY where only a signal-contract mapping does, MAPPINGS_CONFLICT where two that serve it cannot share a bundle), compiling over only those mappings and creating a DRAFT through the one path every draft takes; the route's body cap is CREATE_BYTES, twice the file's limit and 4 KB. tests/test_console_api_policy_rules_file.py, tests/test_policy_preview_spaces.py and tests/test_policy_compare.py hold these.
The console's Policy pages are /policy and /policy/{bundle_id}, reading /api/console/v1/policy above and nothing else. The list marks the active selection, links each bundle by its identity, and carries the new-draft form: the JSON a person pastes, or a copy of the active bundle, neither of which changes anything for admission. The detail reads the bundle: its mappings with their placement in words, its limits, its quorum with the rule behind each obligation, its context settings, its rules as sentences with the effect as a word-first badge, and the compiled policy under "As policy language", read-only. A limit whose scope has one tenant-wide figure carries a meter of use against its ceiling; one counted per agent, per account or per value of a fact prints its ceiling and the phrase saying how it is counted, so a blank meter is explained rather than empty. A rule's note is text and reaches no HTML sink. The preview is asked for rather than run on arrival, over a window from the closed set the API takes, and each changed decision links to /activity?open={execution_id}, the record in Activity's drawer; both effect counts are shown beside the changed list. Activation asks first and names what it changes: the bundle that stops being the selection, and what the preview found when one was run. An ACTIVE bundle offers no Retire, because the store refuses to retire a bundle any space selects: the selection says to activate a replacement first, and a bundle another space selects says it cannot be retired while a space uses it. No change is optimistic: every button waits for the server's answer, and the answer replaces the detail. An approver reaching either page is told it is not theirs to do, because the API refuses them. tests/test_browser_console_policy.py (Chromium: axe clean with no policy or Trusted Types violation in both schemes and at 390 px, both confirmations audited on the settled surface, the whole walk from a pasted draft to an activated bundle, the drawer link, the note as text) and console/src/components/policy/policy.test.tsx hold the behaviour above.
The detail puts Make this policy live directly under what the policy does, as an ordered list of three steps, Validate, Preview and Activate, each with its status in words and the current one marked aria-current="step". The preview's window select and button sit inside the second step, defaulting to 7 days and run only when asked, on a draft, a validated policy, or one active only in another Space; the third step states what the preview found before offering Activate, whose confirmation opens on that line (or "Not previewed"), names what it replaces, quotes the comparison's counts and asks What changed and why. An active policy's step names who activated it and why. Changes from the active policy follows the stepper, one list item per change led by "Allows more", "Allows less" or "Check", rule text through the rules vocabulary and every value as text. The list puts the active selection first, then drafts and validated versions, then versions active in another Space, shows each version's latest note under its time, and folds retired versions behind Show N retired versions, whose state is ?retired=1. Download rules builds the file from the API's answer with a Blob and a detached anchor, and Start from a rules file in the new-draft card refuses a file over 512 KB before reading it, holds its text only until the request settles, and words an action refusal from the person's own file. console/src/components/policy/lifecycle.test.tsx and rulesfile.test.tsx hold these.
A draft's rules are GET and POST /api/console/v1/policy/{bundle_id}/rules, and the page is /policy/{bundle_id}/rules. The read answers the draft's rules grouped by the actions its mappings serve, each with its effect, its sentence, its conditions, its quorum and its note; the budgets, each with the group, account or space it is counted for and the words for it; qualifiers, what a budget may be counted for, read inside the tenant fence (the tenant's principal groups, the active connector accounts the draft's own mappings name, and the spaces the draft may serve that the person may see); the compiled policy; and fields, everything a condition may name for each action — the contract's declared facts and the action's arguments, each with its Cedar type, the operators that type admits (the ordered ones only for a Long) and its declared domain as a closed set or a pair of bounds. The form's selects are built from that and nothing else, so a name or a value the compiler would refuse cannot be offered. The write takes the whole ruleset ({"rules": [...], "budgets": [...]}), so adding a rule, removing one, setting a quorum, editing a note and adding or removing a budget are one operation with one refusal, one audit event and one atomicity; it calls policy.store.change_draft_rules, which locks the draft, refuses unless its state is DRAFT and it carries a ruleset, compiles through ruleset_bundle(), inserts the result as a successor draft, retires the draft it replaced, and appends policy.rules_changed naming both ends, the new digest and the rule and budget counts, never the rules' text. A successor rather than a rewrite because bundle content is append-only in the database: execbound_app is granted SELECT, INSERT on policy_bundle_contents and UPDATE (state, changed_at) on the lifecycle table, so every state the rules passed through stays in the tenant's history as a retired draft. What the caller does not state is carried forward — the draft's mappings, its context settings, its signal mappings and its ruleset's own context — so a surface with no field for one cannot drop it. An undeclared fact, a value outside its domain, a wrong type and an ordered operator on a string each answer INVALID_BODY with the field's location inside the ruleset (ruleset.rules.0.when.0) and never the value at fault, as does a note or a budget's rule id shaped like a password, key or token (ruleset.rules.0.note, ruleset.budgets.0.rule_id), though the two count differently: a compile problem counts the rules in the order they compile, sorted by effect and then id, and shaped text counts them in the order they were written and the budgets in the order of their ids. A budget names group_id, account_id or space_id exactly when its scope is group, account or space, which the model refuses otherwise at the budget (ruleset.budgets.0); one naming a group, account or space the read did not offer, another tenant's among them, answers INVALID_BODY at its field (ruleset.budgets.0.group_id), in the order the budgets were written, without the identifier; a selector scope or an actions list is refused at the body's own field. A state that cannot be changed answers CONFLICT, and another tenant's bundle 404. The page reads the rules with word-first effect badges and their sentences, offers the add-a-rule form with its typed selects, the budget form (counted for each agent, the whole tenant, or one agent group, connector account or space, each qualified scope offered only where the read names something for it and chosen from a plain select, which an existing budget's "Adjust limit" also carries), notes and quorum, and the read-only "as policy language" block; a draft carrying a budget the form cannot express, a selector budget or one over several actions, is read but not edited, the page names it, and the write refuses such a draft at ruleset.budgets so no edit can drop the budget. A budget counted for a group, account or space the read no longer offers reads editable: false too, so the draft is locked and the page names that budget rather than offering an edit the write would refuse at the budget's field. tests/test_console_api_rules.py, tests/test_console_api_rules_signal.py (a signal fact's condition read back, offered with its domain and changed, over an endpoint-isolation.v2 mapping), tests/test_browser_console_policy.py and console/src/components/policy/rules.test.tsx hold the behaviour above.
/board is the live board in the console, and it keeps that model exactly: agents on the left, the checkpoint in the middle, targets on the right, every shot a row the tenant already holds, and a word for every state. It is the console's one full-ink surface, dark in both schemes, the exception recorded under the live board; .ink in console/public/tokens.css supplies the dark values and tests/test_console_tokens.py holds it equal to the dark block, so the exception cannot drift into a second palette. Motion is horizontal translation with six fixed keyframes, and the delay that anchors each shot on its own event time is a style property React applies through the CSSOM, which style-src 'self' does not govern; the policy collector on every route is what checks that claim. prefers-reduced-motion: reduce and Pause both draw the board still at the final position of each phase, with the counters and the ticker still saying every word. Pause is ?live=false in the URL, so a link or the back button restores a stilled board; a paused board holds no connection and asks for nothing. The page reads GET /api/console/v1/board once and is kept current by GET /api/console/v1/board/stream, whose every frame replaces the whole snapshot in the query cache; the connection is closed while the tab is hidden and reopened when it comes back, so a wall display in a background tab makes no request at all. A closed event ends the connection and sends the person to sign in, as a 401 does on every other route. At phone width the picture keeps a legible width and pans inside its own keyboard-reachable region; the page itself never scrolls sideways. A ticker row does not yet open its record, because the drawer's search parameter belongs to Activity's route; the card links Activity instead. tests/test_browser_console_board.py holds the behaviour above in Chromium and console/src/components/board/board.test.tsx holds the geometry, the counters, the ticker and the stream hook. tests/test_browser_console_soak.py leaves the board open with the stream sending, and holds the JavaScript heap to less than twenty per cent growth since a fifth of the way in while counting the frames that actually arrived, then hides the tab and holds it to no request and no frame at all; EXECBOUND_BOARD_SOAK_MINUTES sets its length and Home's, three minutes by default and ten for the full ten-minute soak.
Without a plugin
A deployment whose plugins map names no plugin runs the self-hosted core, which is what this page describes. The hosted service's plugin adds routes, pages, commands and console cards of its own, documented with the hosted service. In the core:
- the page footers' Help, Privacy and Terms, the console footer's Privacy, Terms and Status, the account-help links and
GET /api/console/v1/sessionservicecome from the runtime configuration'sserviceblock, and without one none of them is shown; the footers' Source link andsource_urlname the public repository unless that block names another address ornull, and the footers credit the software ("Powered by ExecBound") unless the block names anoperator; - the runtime role holds nothing on the dormant tables and functions (Data model), and a database that grants it anything is refused (
/health/readyanswers 503); - a command on the runtime connection other than
quickstart, or/health/ready, against a database holding grants for a plugin the process has not loaded exits 2 with the sentence namingEXECBOUND_PLUGINS, or answers 503; - keys the runtime configuration's schema does not declare are refused as unknown;
- workspace switching (
can_switch_workspaces,GET /api/console/v1/session/workspaces,POST /api/console/v1/session/workspace) and the sample target routes are core's own.
The console's assistant
/api/console/v1/assistant is the server side of the console's assistant, with the panel and its Explain entry points. The addresses exist only where the private configuration carries an assistant block: a deployment that configured no model serves none of them and answers 404 because the path does not exist, exactly as / does without the console. Where the block is present, a tenant whose ADMIN has not turned the assistant on is refused ASSISTANT_OFF.
POST /assistant/turns creates one turn and answers with it at once, as it starts; the model loop runs behind the response in this process, writing each step to the turn's row. The body is a question of up to 1,000 characters, or a context_kind and context_id naming a record to explain (an execution, an approval, a finding, an agent or a policy change), or both; a contextual turn re-reads that record with the asking person's own authority before the model sees anything, so an id the person cannot read is refused as the page refuses it. GET /assistant/turns lists that person's recent turns and GET /assistant/turns/{turn_id} reads one: their own only, so an administrator cannot open an approver's question. GET /assistant/turns/{turn_id}/events follows a turn as server-sent events, one step event per step the row gains and one done event carrying the finished turn, closing when the turn ends or at ninety seconds; it reads the row and nothing else, so a restart mid-turn, a second gateway process or a person opening the page late all see what the row says. POST /assistant/turns/{turn_id}/opened records that a person opened a generated explanation, appended as assistant.explained, so a later review can see which decisions were made after reading one. GET and POST /assistant/settings are the ADMIN's: the tenant switch with a required reason, audited as tenant.assistant_changed, answered with the model's name, the caps and one sentence naming what the provider retains.
POST /assistant/rules drafts one rule for a draft from an administrator's own words and writes nothing: it answers with the rule as the add-a-rule form takes it, its sentence, and its conditions in the words the editor parses, having already compiled it with that draft's other rules against that draft's mappings. A proposal the compiler will not take, or one holding text shaped like a credential, answers INVALID_PROPOSAL naming the field and never the text: its id is written into the audit chain, and the event export refuses a window holding such text for good. Only a DRAFT written as rules can be drafted into; anything else is refused as the rules page refuses it.
Every answer the console renders is plain text with no markup, and a citation is a link only to an id a tool returned in the same turn. The engine behind these routes is execbound/assistant/, described in Policy; nothing it does reaches a provider or writes anything but the turn's own row.
/ask and /ask/{turn_id} are the console side of those routes. The entry exists only where the operator's assistant block does: GET /api/console/v1/session carries assistant, so the shell knows before it paints and a console built for a deployment without one shows no navigation entry, no top bar button, no palette group and no Explain, and /ask is the shell's own not-found. Below 1280 px the top bar's Ask button shows its icon alone and keeps its name as its label; up to 480 px wide, and below 560 px wherever an edition's action marked the same way sits beside it, the top bar leaves Ask out and Menu's navigation sheet carries Ask instead, so the bar fits beside a second workspace's chip. Whether the tenant turned it on is GET /assistant/settings, which is an administrator's route, so an administrator is told before they type and offered the way to Workspace settings, while an approver simply asks and is refused ASSISTANT_OFF if the switch is off. Ask is a question box and the asking person's own recent turns, which are theirs alone: an administrator's list holds their own questions and nobody else's. A question answers with a turn and the page goes to /ask/{turn_id}, where the steps arrive over the events stream as the server takes them, then the answer. An answer is paragraphs of plain text with a chip for each id a tool returned; the server says which ids those are and not what kind of record each names, so a chip is a link only for the turn's own context, whose kind the turn states, and inert text otherwise. The command palette's Ask group carries the typed words to /ask?q=, where the person reads them back and presses Ask: the palette never asks anything itself. Explain sits below a record's own facts, collapsed, labelled "Generated explanation. The facts above are the record."; opening it creates the turn and records recordAssistantTurnOpened, and it is on the approval detail, the activity drawer, a policy preview's changed decisions, the agent detail page and each monitoring finding. Workspace settings gains the Assistant card: the switch over /assistant/settings, which only a workspace administrator may turn (set_tenant_switch refuses an administrator narrowed to spaces INVALID_IDENTITY and the route answers 403; they read the switch, and the card offers them no button), what the assistant is and is not, and the sentence naming what the model provider keeps, stated again in the dialog that turns it on with its recorded reason. console/src/components/assistant/*, tests/test_browser_console.py and console/src/components/assistant/assistant.test.tsx hold the behaviour above.
"Describe a rule" is that route's console side, on /policy/{bundle_id}/rules above the add-a-rule form. An administrator says what the rule should do; what comes back fills the form beside the words that produced it, with the sentence it reads as and its conditions in the words the editor parses, and every field stays theirs to edit. The card is absent where the operator configured no model, hidden where the tenant has not turned the assistant on, and hidden on a bundle that cannot be edited. Nothing is added to the draft by describing one: the rule reaches the draft only when the person presses Add the rule, which is the same submission as any other, and a proposal the compiler would not take is refused before it reaches the form with one sentence saying what to say instead. console/src/components/assistant/DescribeRule.tsx and the Chromium walk in tests/test_browser_console.py hold the behaviour above.