Skip to main content

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 an ActionRequest.
  • GET /v1/executions/{execution_id}: current AGENT credential, own tenant and principal only.
  • 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 /pre and POST /post: a control point's Pre- and Post-Execution hooks, served only when the private configuration carries an arcade block and normally reached through the separate create_arcade process. The same AGENT authentication and the same request discipline as the routes above, and a different body rule: the monitoring intake's, which admits application/json and application/json; charset=utf-8 compared case-insensitively and nothing else, each route under its own byte bound. See Arcade logic extension.
  • /mcp/: authenticated stateless Streamable HTTP, with execute({"request": ...}) and execution_status({"execution_id": ...}) tools. Use the trailing slash.

All agent requests carry Authorization: Bearer <credential>. The prototype uses pre-provisioned opaque credentials. 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.2.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 every continuation issued before this unit is byte-identical. 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

The console is a single-page application served at the console's own paths (design, #333; the switch is phase 3). GET /, /activity, /approvals, /approvals/{approval_id}, /agents, /agents/{agent_id}, /approvers, /ask, /ask/{turn_id}, /board, /credentials, /monitor, /monitor/agents/{agent_id}, /people, /people/{principal_id}, /policy, /policy/{bundle_id}, /policy/{bundle_id}/rules, /settings, /spaces, /tenant, /invitations and /welcome each serve the application's one document to a signed-in person. Those are exactly the addresses the application has: the server enumerates them rather than answering from a catch-all, tests/test_console_serving.py holds the list equal to the application's own generated route tree, and an address outside it answers 404 rather than a shell that would render "not found" with a 200.

A person signs in with an email and a password, with a configured provider, or with a credential. GET /login carries all three: the address and password form posting to POST /session/password, one Continue with <provider> button for every provider the private configuration's sign_in block names, and the review credential form posting to POST /session unchanged. 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. GET /set-password and POST /set-password redeem a one-time code an administrator or owner tooling minted. 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.

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. One 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 #178's claim code and #241's invitation code are. 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.

Seven 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), /signup (and the page every submission answers), /onboard (and the page a claimed sandbox renders), the HTML error pages, /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 — and /showcase, the public board. They take a small stylesheet of their own, /assets/app.css, on the console's tokens and IBM Plex; the confirmation module they used to share is gone, and the only script any of them loads is /assets/showcase.js, which reloads the showcase and does nothing else.

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}, /welcome/sandbox.json and /welcome/candidate-bundle.json to their console API counterparts, 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 a separately issued HUMAN_APPROVER or ADMIN credential. Sign-in exchanges that credential for an opaque session and CSRF cookie; only hashes are retained in PostgreSQL. Sessions expire after 15 idle minutes and never outlive the original credential; a page load made after half the lifetime renews the session for another 15 minutes, never past eight hours from sign-in, and the response re-issues the cookies with the new age, so a reviewer who keeps reading stays signed in. 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. Five active sessions per credential are permitted. Logout revokes the retained session immediately. Credential expiry changes, revocation, role changes and disabled principals/tenants invalidate existing sessions.

HUMAN_APPROVER sees Approvals and Activity within assigned canonical-target/action scope, and 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, #333 decision 8) 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 (frontend approach). 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 the acting ADMIN inside the database and refuse ADMIN principals and credentials, so the gateway holds no table write privilege and cannot create or change administrators (credential administration pages design). 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 (Tines collection design). 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.

Four routes serve sandbox onboarding (design, open signups). /onboard is public: a form for an operator-minted invitation code and a tenant name, CSRF-cookied like /login, refused on any query string, bearer header, wrong origin or extra field before the database is touched, rate limited per peer and per invitation selector, and handed to the onboarding_claim definer function inside the tenant fence. Success renders the new administrator credential once with the gateway origin and a sign-in link and deletes the page cookie; every refusal shows one sentence ("This invitation is not valid."), except a taken name, which a holder of a verified invitation is told about. The code and the selector reach only the structured log. /signup is public and exists only when the private configuration carries a signups block, which names the operator tenant the requests live in, the seeder's slots file, the versioned consent the form records and the mode; without the block the gateway answers 404 at both methods. It is CSRF-cookied like /login and refuses a query string, a bearer header, a wrong Origin or an extra field before the database. The form asks for a work email, a consent checkbox naming the privacy notice and terms on the public site, and a clipped honeypot field no person is shown. Every submission gets one page with one status — an allocation, a place on the waitlist and each of the eight refusals alike — so nothing there tells a stranger whether an address is already known, whether the pool is empty or which cap they reached; the codes reach the structured log and the operator's own list. Allocation walks the slots file lowest first, skipping the reserved showcase seed, and mints an invitation whose initial hash is a discarded random digest, so nothing can redeem it until the send rekeys it and mails the code it just minted. The controls that bound a stranger are counts taken inside signup_request under the operator's fence, because behind a reverse proxy the per-peer window the route keeps is a flood guard on the process rather than a limit on any one caller. In closed mode the page says invitations are paused and records nothing at all. /welcome is for an ADMIN session: the slot, the fixture hosts with the SOC pack's effect stated per class (allow, require approval, deny) beside the rule, the open incidents, the two things to do first on the credentials page and whether they are done, the gateway origin and the tenant's approval deadline (with the instruction to sign in on the phone before the approval demo), a progress table computed from the tenant's own audit chain with no script and no analytics (claimed, agent and approver credentials issued, the first governed request, a denial, a claim refused before any decision, a decision from the console, the shared limit refused; each done with its time or not yet with the beat that does it; the policy preview row links the console and never ticks; the minutes from the claim to the first governed request once both exist), the six scenarios mapped to the three execbound-demo beats with the install line and the two downloads, and the recipes for Claude Code, Cursor, the OpenAI Agents SDK and the Claude Agent SDK, each rendered from a template with the origin as its only variable and pinned byte for byte by tests/test_recipes.py, the credential never on the page; one line says Claude Desktop and claude.ai connectors wait for the MCP authorization server. A tenant that is not a claimed slot gets the short form with the administration links. Two ADMIN-only downloads sit beside it: /api/console/v1/welcome/sandbox.json, the demo driver's description of the slot without credentials (the same classification the seeder wrote into the slot's file, so the two cannot drift), and /api/console/v1/welcome/candidate-bundle.json, the active bundle with autonomous principal budgets of three for the preview demo; both are attachments, never cached, and pass the replay export's secret check before they are sent. Both pages pass the phone-width accessibility audit.

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. The two views show the latest 50 scoped executions; they are intentionally bounded, without a broad administration console. Jinja's API documentation describes explicit autoescaping.

The pages that stay and the console share one design (#333 decision 1, which supersedes the design pass of #206 for the console): 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, byte-identical to the site's.

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 (#333 decision 7) and paints a plain frame from the tokens before any script runs (decision 10), 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, and the browser frame on the public site is dark in both schemes because the console screenshot inside it is.

Two settings pages (design, #253). /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 eight hours old and the form carries the CSRF token and a confirmation, which the threat model records as the accepted re-authentication. 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. /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. A read-only block shows the tenant name and id, the active bundle, the sandbox slot when one exists, monitoring health with links, and the configured notification destinations as id, kind, host and approver count, never the URL or the secret. The directory identity section, the self-link button, the push devices section and the usage block wait on #241, #179 and #257 and say nothing until they exist. Settings and, for an ADMIN, Tenant 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, with and without scripts.

The People pages (design, the People half of #241) are ADMIN only and cover the humans of a tenant. /people lists every HUMAN_APPROVER and ADMIN principal with label (or "unlabelled"), kind, state, live credentials, active sessions, last sign-in and, for approvers, the number of 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"); an approver's 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. Directory identity and invitations, history and groups are later steps of the design and say nothing until they exist. People opens the Access group of the console's sidebar. Both pages pass the phone-width accessibility audit and the contrast probe in both schemes, with and without scripts, and the export secret check.

Step 3 of the People page (rulings) adds History to the person page, built ahead of step 2, which waits on the identity tables of #177. 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 (design, step 1 of the Agents page on #228) are ADMIN only and cover the tenant's AGENT principals. /agents lists them with label (or "unlabelled"), 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, the billing unit of #233, with that definition printed beside it; the list, the figure and the agent page read one query, which the tally of #257 replaces when it exists. 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 (#273, step 4); its last twenty requests, linking to Activity filtered by the agent; its limits, every principal-scoped rule of the active bundle and the tenant- and group-scoped rules for the actions it has used, 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- and selector-scoped rules 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. Discovered agents stay on Monitoring's Agents view until step 2 merges them into this list with bindings and Govern this agent. Agents sits under Govern in the console's sidebar. Both pages pass the phone-width accessibility audit and the contrast probe in both schemes, with and without scripts, and the export secret check.

Step 2 of the Agents page (rulings) brings the agents a monitoring connection lists into the same list. 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 303 to its principal's page. 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 renders the show-once page with the install line and the Claude Code MCP 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.

Step 3 puts the tenant line 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 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 same value is rendered server-side for /showcase, the one page here a stranger can read, and the two cannot disagree because they are one read.

It is the console's one full-ink page, recorded as the exception to the design pass, because it is a display surface for a wall or a screen share.

Motion is horizontal translation with fixed keyframes. A lane's position is a transform attribute the template writes, never a style attribute, and there is no SMIL at all: the Content Security Policy forbids inline style, and Firefox blocks SMIL on presentation attributes under a policy (Bugzilla 1459872 and 1683972). Each shot carries an age-N class, one per tenth of a second, that supplies a negative animation-delay, so a reload lands every shot within a tenth of a second of where it was. Those 151 rules are generated into app.css by scripts/render_age_classes.py and a test re-runs it and compares; nothing is generated at request time.

The server-rendered copy moves by reloading itself: /assets/showcase.js reloads the page every data-refresh-seconds while the document is visible and never otherwise, and it fetches nothing and decides nothing. ?live=0 pauses: the module is not loaded at all, the pulses stop and every shot is drawn at the final position of its phase with its word beside it, which is also what prefers-reduced-motion: reduce gives. With scripts blocked the board is a still picture with a Refresh link, and WCAG 2.2.2 is met by the pause. A target's label and its shots are drawn on the same row, read from one map of the lane's server-assigned index to its sorted position, so a mark never sits a line away from the name it belongs to.

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. 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".

live is the only query key /showcase accepts. Any other key, or a repeated one, is refused by the same boundary guard every page here uses; nothing in a query string selects a tenant.

Private configuration and startup

Apply current Alembic migrations through 0027_approval_deadline with the owner connection. The earlier 0006_human_sessions revision 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. Its exact shape is:

{
"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
}],
"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>"
}],
"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},
"sign_in": {
"google": {
"client_id": "<OAuth client id>.apps.googleusercontent.com",
"redirect_uri": "https://app.example.com/login/google/callback"
},
"github": {
"client_id": "<OAuth App client id>",
"redirect_uri": "https://app.example.com/login/github/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. 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. 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. 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 (#411). 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. 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.

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/identity endpoints only. 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 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.

This unit provides local/private surfaces. Production identity provisioning, real vendor integration, public deployment and broad host interoperability remain separate work. Offline replay and the validation package are implemented. A configured gateway process and its cross-tenant database credential remain trusted components.

The public site

site/ holds the one-page public site for execbound.ai (#205): hand-written HTML and CSS with no build step, so what is committed is what is served. It is not a gateway route and shares no route authority with the console. It is served by its own Render static site (execbound-site in render.yaml, staticPublishPath: ./site, no build command, automatic deploys off), reaches no database, holds no session, reads no tenant and takes no input; its only forms of interaction are a mailto: link and plain links out, to the gateway's own sign-in page and to documents on GitHub. Its Content Security Policy is set in render.yaml rather than by the application, and permits nothing but this origin's own styles, images and fonts, which is what keeps the page free of third-party requests. The page carries no script at all, so the console's script-src 'self' has no counterpart here.

The site shares the console rebuild's tokens, type and checkpoint mark (#333: a graphite base in both schemes, signal cyan as the one accent, IBM Plex self-hosted) so the two read as one thing, but it has its own stylesheet, its own copy of the fonts and its own copy of the mark; nothing is imported across the boundary, and a test holds the site's two token blocks equal to console/public/tokens.css. Every factual sentence on the page traces to README.md "What works today" or the acceptance inventory, and site/README.md carries the sentence-by-sentence table, the deployment and DNS steps and what still waits on other issues. tests/test_browser_site.py holds the page to all of it in Chromium: no request off its own origin, no script element or event-handler attribute, AA contrast on every visible text element in both schemes with alpha backgrounds composited, the tokens equal to the console's and no colour literal outside them, IBM Plex loaded from this origin and the mark's bar in the accent, the site's policy permitting nothing foreign, one column with no horizontal scroll and 44-pixel controls at 390 pixels, sound headings and landmarks, no vendor named as an integration and no production-readiness claim. The rules that belong to a document rather than to this page are parametrized over every .html file in site/: each one links styles.css and no other stylesheet, carries no script and no foreign subresource, paints the token background in IBM Plex Sans, and meets AA contrast in both schemes. A rendered page written here later (#269, #250) therefore takes the site's look and its rules the day it is committed, without a change to the tests.

Render's Blueprint sync created the service on September 18, 2026, when its render.yaml entry merged, and the page has been served at https://execbound.ai since the same day, with www redirecting to it; automatic deploys are off, so a change to site/ is served only after a manual deploy. site/README.md records the DNS records and how they were set.

Rules

/policy/{bundle_id}/rules is the ADMIN editor for rulesets (#273). It works without scripts: the in-progress ruleset travels in the form as one hidden field, re-validated by the Ruleset model on every round trip, and each add of a rule or a budget is a post that re-renders. Conditions are written one per line in the words the pages print them in (role is workstation, argument reason is false_positive, criticality is at least 3, role is one of server, domain_controller), because the console's form parser takes at most eight fields per post; a budget's ceiling is one field, 3 per 3600 seconds. The rules apply to the actions the tenant's active bundle maps; without one the page says so. 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.

Step 4 makes the pages read the rules. When the active bundle carries a ruleset, the welcome page's effect column is project()'s reading of the rules over each fixture host's facts (the contract's names and types, the open incident's status and severity) and names the rule the reading rests on: the denial that applied, else the allow and the review; a host with no open incident presents no incident facts and the column says which facts it lacked rather than guessing. The rules for the actions the column is about are listed as sentences above it. The agent page gains a Rules column beside each governed action, the rules about that action in the compiler's order. Every reading is labelled "as the rules read; the decision is the kernel's", and tests/test_pack_rulesets.py proves the reading equal to the kernel's policy decision over the whole input space of every pack; a hand-written bundle shows what it showed before and adds no claim. GET /policy/rules?start=active opens the editor on the active rules and budgets, ?start=candidate on the same with every autonomous principal budget capped at three targets, the change the sandbox demo previews, so that demo is a rule edit rather than a JSON paste; any other query key or value is 400, and a hand-written active bundle says so and starts the draft empty. The sandbox seeder installs the SOC pack as rules (sandbox_bundle_for), and GET /welcome/candidate-bundle.json from a ruleset bundle is a ruleset bundle, with the budgets the rules state capped exactly as the limits they compile to, so it validates when pasted.

How the console is served

The console is a single-page application (design, #333). It is not optional and has no flag: the / prefix and console.enabled both went at the switch, and 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 gained font-src 'self' at the switch so those pages can take the packaged IBM Plex, and img-src 'self' on 21 September 2026 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.

The console API lives under /api/console/v1/ (design, decision 5). It is private to the console and session-cookie only: every route runs the pages' own boundary (the query allowlist, the Host pin, the identity-header refusal, a bearer credential refused with 403 and audited as execution.rejected), loads the session from its cookie and checks the CSRF cookie; a mutation is always a POST and additionally needs 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 is the pages' 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. 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 (#333 lane E, step 2), the session's expiry, the release and the CSRF token, which is the one place the token leaves the server as data, under Cache-Control: no-store. 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 OpenAPI document of this API alone is committed at console/openapi/console-v1.json and the client generated from it at console/src/api; scripts/console_openapi.py --check and the console CI job hold both current. tests/test_console_api_base.py and tests/console_api_fixtures.py hold the behaviour above and are the harness every lane's routes reuse.

GET /api/console/v1/board is the live board (#280) 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, the ticker, the overflow counts, the counters for UTC-today 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. 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 at all: ?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.

The settings area (#333 lane E, step 1; #253) is seven routes over the functions the two Jinja settings pages call. 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, and every live session of that principal (id, created, expires, the fixed device label, whether it is this one) with a count of the others; 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. 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 sandbox slot when one exists, the monitoring counts, and the configured notification destinations as id, kind, host 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; a no-op answers 409 CONFLICT, an empty reason 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 approval routes (#333, lane B) are the approval pages over JSON, and an approver's as the pages are: an administrator is refused FORBIDDEN on all three, as /approvals refuses them today. GET /api/console/v1/approvals lists the approver'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 (#426); 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. 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 approver's own decision when one is on record; 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, and, only while pending, the signed continuation the decision must carry, the same one the form's hidden field holds. An unknown, foreign or out-of-scope approval is NOT_FOUND either way. 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 Approvals page of the rebuilt console (#333, lane B) lives at /approvals and /approvals/{approval_id}, reading only the three routes above. On a desktop the queue (Pending, Decided today, and Lapsed, each with its count) sits beside the detail, and a row opens its approval in the URL so a link or the back button restores it; 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 waits follows, and the two decision buttons are pinned above the tab bar in a 1:2 grid at 50 px. 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 lane. 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). An administrator, whom the routes refuse, is told where to watch instead. 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 (#427). 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, axe clean with zero policy violations in both schemes at both widths).

Lane A's routes (#333, Home and Activity) 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, 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, 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. /api/console/v1/agents is the Agents page as JSON (#333, lane D, step 1), 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 bearer credential is refused and audited, and a cross-tenant, human or malformed id answers 404. 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, and the tenant line with its source denominators. 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, answering the show-once credential under no-store), .../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, CONFLICT, INVALID_PRINCIPAL for a principal the console may not change, FORBIDDEN, UNAVAILABLE). 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 (#333, lane D, step 2), 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 bearer credential is refused and audited, and a cross-tenant, agent or malformed id answers 404. GET /people is the list 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) and whether the person is the signed-in administrator. 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), .../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. 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 (spaces design): 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. 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 spaces plan, PR 3). 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. 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>, 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 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 (refinement 8a): an approval link opens the approval, its answer names the space that owns it, and the shell follows it there and says so. 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 (#333, lane D, step 3), 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 and a bearer credential is refused and audited. GET /credentials is every credential of the tenant 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 (INVALID_PRINCIPAL); POST /credentials/{credential_id}/revoke revokes one and answers the list as it is afterwards, an administrator's or an already revoked credential 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. The monitoring area (#333 lane E, step 3) is an administrator's (kind="ADMIN" on every guard) and wraps the functions /monitor/{view}, /monitor/agents/{id} and the acknowledge form call today. 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, the discovered credentials as metadata, the recent observations with how each was obtained, the findings joined to their observation, the delivery intents, the active detection profile's version, and the count of open findings, each list bounded at 200 as the pages are. GET /api/console/v1/monitor/agents is agents_snapshot (the agents with their counts, the consequential actions, and the tenant line that states its own denominator) and GET /api/console/v1/monitor/agents/{agent_id} is agent_detail (the identity, the grants with the actions the reviewed catalog explains, every reach row with its effective certainty, the findings and the observations attributed to it, and how many grants could not be explained); an identifier that is not one, or an agent that is not this tenant's, answers 404 NOT_FOUND. POST /api/console/v1/monitor/findings/{finding_id}/acknowledge takes an empty body and is acknowledge, idempotent, answered with the fresh snapshot as the page re-renders after its redirect. Every platform-supplied string travels as data and the client renders it as text. The sidebar's Monitoring count is the tenant view's open findings, polled every ten seconds by an administrator's shell. 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}.

/api/console/v1/invitations is the Invitations area (sandbox onboarding, "Minting an invitation from the console"; #178, #204): the sandbox slot pool, so the founder invites a named prospect from a phone instead of from a workstation with an owner connection. The authority is two server-side conditions and nothing a request carries: the session is an ADMIN, and the session's tenant is the one signups.tenant_id names, which is the operator tenant that administers the pool. A deployment with no signups block, and every other tenant including the pilot and every claimed sandbox, answers NOT_FOUND on all four routes, so a tenant that is not the operator's learns the address does not exist for them rather than that a pool exists somewhere; a customer administrator cannot mint because minting writes into another tenant and pre-authorises that tenant's first ADMIN, and no administrator has authority outside their own fence. Within the operator tenant the routes are a tenant administrator's (an active ADMIN with no space_members row), recomputed in the database on every request: the area is tenant-level and takes no space filter at all, because a slot is a tenant, an invitation is a row inside it, and neither table carries a space_id. GET /invitations is every invitation of every slot in the configured pool, newest first, with the slot's number and seed, the note, the state (live, redeemed, expired, revoked), the times and the wrong-attempt count, plus how many slots are free and how many the pool holds; no tenant id of another tenant ever reaches the client, and an invitation is addressed by its own identifier, whose slot the server finds by walking the pool, so an identifier typed into a request cannot reach an invitation the operator does not administer. POST /invitations takes {note, lifetime, email?} and invites the lowest free slot, skipping the reserved showcase seed as the open signups allocator does; the lifetimes are one, three, seven, fourteen and thirty days and onboarding_invite bounds them again. With no address the code comes back once in that response, which is no-store like every console response, and the client shows it in a dialog that closes on its own button and holds it in no query cache, no URL, no storage and no log; with an address it goes through the existing relay inside the request, the invitation is revoked before the answer unless the relay accepted the message, and no code is returned at all. POST /invitations/{invitation_id}/resend mints a new code for an invitation that is already live, taking no second slot: only the last code's digest was kept, so onboarding_invitation_rekey replaces it and the previous code is dead from that moment. POST /invitations/{invitation_id}/revoke ends one that was never used and answers the pool as it stands, which frees the slot because the partial unique index counts a live row. The events are the operator tenant's: sandbox.invited, sandbox.invitation_resent and sandbox.invitation_revoked, each naming the slot tenant, the slot and the invitation and whether a message went out, and none of them carrying the code, the recipient address or the note. tests/test_console_api_invitations.py and tests/test_onboarding.py (SO18, SO19) hold the behaviour above.

The console's own Invitations page is /invitations in the Access group, one heading, a Mint an invitation button and the pool as a table with Send again and Revoke on a live row, each behind the confirmation dialog the directory pages use. It is the one administrator's page that also reaches the phone tab bar, because the sidebar is not on screen under 768 px and a page only in it would be reachable by typing its address and no other way, which is exactly what this page exists to avoid. console/src/components/invitations/invitations.test.tsx and tests/test_browser_console.py hold it, the latter at 1280 and 390 pixels in both colour schemes with axe clean and no policy or Trusted Types violation.

The Getting started area (#333 lane E, step 4) is an administrator's. GET /api/console/v1/welcome is welcome_overview: the sandbox slot when one is claimed, the fixture targets with their hostname, class, environment and what the SOC pack does to the first action on each, with the rules that reading rests on and the bundle they belong to whenever the active bundle was written as rules, and the facts a reading had no value for where it had none (#273, step 4), the open incidents, the pre-created approver, the live agent and approver credential counts, the gateway origin, the pack's rules sentence, the approval deadline, the progress rows read from the tenant's own audit chain, the minutes from the claim to the first governed request, the demo's workstation with its incident, and the recipes as plain text with no credential in them; a tenant that is not a sandbox slot answers the same shape with the slot null. GET /api/console/v1/welcome/sandbox.json and GET /api/console/v1/welcome/candidate-bundle.json are the welcome page's two downloads moved beside it: attachments under Cache-Control: no-store that pass the secret check, the first answering 404 NOT_FOUND for a tenant with no claimed slot and the second for a tenant with no active bundle. The page renders every recipe as text in a focusable, named <pre> and never shows a credential; the sidebar's Getting started card reads the same view every minute for an administrator and shows the steps done of the total, as a meter, with the first still to do. tests/test_console_api_welcome.py and tests/test_browser_console_welcome.py hold the behaviour above; the page is /welcome.

The rebuilt console's Home (#333, lane A) 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 (#426). 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 (#427). "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; 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.

The rebuilt console's Activity page (#333, lane A) 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. ?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 rebuilt console's command palette (#333, lane A) opens with Ctrl or Cmd K or the sidebar's "Search or ask" and 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, and, for an administrator, an agent by label; 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=), and each of those two also adds a "Go to" entry saying so, beside the pages whose names match. An empty answer says "No record matches in your scope." The Ask group stays a placeholder until the assistant lane. 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, and tests/test_browser_console_activity.py the palette in Chromium for an administrator and for an approver outside their scope.

/api/console/v1/policy is the Policy pages as JSON (#333, lane C, step 1), 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 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, 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.

The Policy pages of the rebuilt console are /policy and /policy/{bundle_id} (#333, lane C, step 2), 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; retiring the selection says that admission fails closed until another bundle is activated. 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.

A draft's rules are GET and POST /api/console/v1/policy/{bundle_id}/rules, and the page is /policy/{bundle_id}/rules (#333, lane C, step 3, building the read and write functions #273 specified and did not ship). 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; 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 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; 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 for the two scopes that need no qualifier, notes and quorum, and the read-only "as policy language" block; a draft carrying a budget the form cannot express is read but not edited and the page names it. tests/test_console_api_rules.py, tests/test_browser_console_policy.py and console/src/components/policy/rules.test.tsx hold the behaviour above.

/board is #280's live board in the rebuilt console, and it keeps that issue's 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, which is #206's recorded exception carried into the rebuild; .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 the same six keyframes the server-rendered board uses, 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 generated block of 151 age classes the Jinja page needs has no counterpart here, and 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, three minutes by default and ten for the figure the design asks for.

The console's assistant

/api/console/v1/assistant is the server side of the console's assistant (#330), the panel and its Explain entry points being lane G of the rebuild. 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 answers INVALID_PROPOSAL naming the field. 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 and in the design; 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 (#333, lane G). 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. 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 Tenant 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. Tenant settings gains the Assistant card: the switch over /assistant/settings, 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 (#333, lane G, step 3). 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.