Operations
Every execbound command and monitoring command (python -m execbound.monitoring.commands) below, with each argument it takes, is also listed in the command-line reference, which is generated from the commands themselves. The scripts under scripts/ and the uvicorn, docker compose and PostgreSQL commands are not; each script this guide runs prints its own arguments with --help.
The Tines monitoring guide documents the separate source-authenticated HTTP endpoint, ADMIN pages, operator commands, local worker/receiver, tests and current delivery limits.
The supported reproducible environment is local PostgreSQL plus a gateway and two mock-provider processes, either the one-host install (Install) or the validation harness from a clone. This guide documents current controls and recovery behavior, not a production availability commitment.
Configuration inventory
| Setting / file | Consumer and purpose |
|---|---|
DATABASE_URL | Restricted gateway/export connection, driver postgresql+psycopg; Settings reads .env for local use |
MIGRATION_DATABASE_URL | Separate schema owner for explicit Alembic/provisioning commands |
TEST_DATABASE_URL, TEST_MIGRATION_DATABASE_URL | Exact guarded loopback execbound_test connections used by destructive tests |
POSTGRES_PASSWORD, EXECBOUND_APP_PASSWORD | Local setup owner/runtime passwords, generated into ignored .env; keep them paired with the existing local data |
EXECBOUND_RUNTIME_CONFIG | Private JSON path composing execution, MCP and human surfaces |
plugins block in the gateway JSON | Names the first-party plugins a server process loads, at most eight, each with its own section that the plugin validates. Absent, none loads, and the self-hosted server needs none. A changed block needs a restart |
EXECBOUND_PLUGINS | Comma-separated plugin names the command line loads; must be in the real process environment, because commands are registered before .env is read. execbound plugins lists what it enables |
EXECBOUND_MOCK_DATABASE_PATH | Provider process's private SQLite path |
EXECBOUND_MOCK_ACCOUNTS_PATH | Provider process's private account/credential-digest/signing configuration path |
EXECBOUND_MOCK_PROVIDER | Provider kind, mock_crowdstrike or mock_entra |
EXECBOUND_MOCK_SAMPLE_KEY | The endpoint mock's sample key for sample targets: 32 to 128 bytes as lower-case hex, equal to the gateway's sample_providers.mock_crowdstrike.sample_key. Absent or empty, the mock serves configured accounts only. The mock reads it itself at start; a malformed key, a key equal to a configured account's signing key, or any key on the identity mock stops the mock. A change is a restart and cuts every workspace's sample connection until the gateway holds the same key. A sample request dispatched in that window, or refused by the mock because the mock's own configuration holds an account in that tenant, is answered 401 and left INDETERMINATE. Once the gateway and the mock agree again, Reconcile with cancellation settles it, from the console or with execbound reconcile --cancel-if-missing: the mock holds no journal for it, so the lookup answers 404 and the cancellation's tombstone settles it as not executed. The gateway derives no sample connection in a tenant it holds a configured connector for, so a workspace holding a configured account is not offered samples, POST /api/console/v1/samples answers SAMPLES_UNAVAILABLE there, and a sample request there is refused at admission before anything is sent |
sample_providers block in the gateway JSON | Sample targets: keyed by provider (only mock_crowdstrike), each with the mock's origin, the sample_key and timeout_seconds (default 5). Absent, guided setup offers no sample targets and POST /api/console/v1/samples answers SAMPLES_UNAVAILABLE. A sample dispatch records ENFORCED_BY_PROVIDER only when the preconditions block also declares mock_crowdstrike's contain and lift_containment that way, the same declaration a configured connector's dispatch needs. A change is a restart, never a key reload; see Surfaces |
assistant block in the gateway JSON | The console assistant: model (default claude-sonnet-5), base_url, tenant_daily_tokens (default 200,000), person_hourly_turns (default 20), effort (low by default). Absent, the assistant does not exist. Each tenant's workspace administrator turns it on with tenant-assistant |
EXECBOUND_ASSISTANT_API_KEY | The model provider's key, merged into the assistant block by boot.py when the block carries none, never both; consumed and removed from the environment before the server starts |
sign_in block in the gateway JSON | The sign-in providers this deployment offers: google, github and microsoft, each with client_id and redirect_uri. A provider with no block serves no route and shows no button. Every endpoint is pinned in source; the redirect URI must be this deployment's own origin plus /login/<provider>/callback or the server refuses to start |
EXECBOUND_GOOGLE_CLIENT_SECRET, EXECBOUND_GITHUB_CLIENT_SECRET | Each provider's OAuth client secret, merged into its block by boot.py when the block carries none, never both; consumed and removed from the environment before the server starts |
sign_in.directory block in the gateway JSON | The key ring a tenant's own identity provider's client secret and refresh token are sealed under: active_key_id and keys, each 32 bytes of hex. With no ring a tenant can register no provider, the tenant settings card says so and /invite and the directory routes answer 404. Rotation is a restart rather than a key reload, because these keys open data at rest: a reload that dropped one would leave rows nothing could open. A retired key stays in the ring and every row it sealed keeps opening; a row is rewritten under the active key the next time it is written |
local-data/<fixture>/ | Generated private gateway/provider configuration, credentials, ADMIN token files, SQLite stores and fixture manifest |
The complete gateway JSON is defined in surfaces/configuration.py and illustrated in Surfaces. Top-level fields include origin, active_key_id, continuation_keys, connectors, an optional limits object that tunes the request windows described under Health and capacity, optional notifications, and an optional preconditions block (up to 64 entries of provider, provider_operation and precondition_class) that declares what each provider binding's vendor does with a frozen expected version for the external execution checkpoint. An undeclared binding answers UNAVAILABLE and its grants carry no version, and declaring a class is a statement about the vendor's API that ExecBound cannot verify. Each connector binds tenant_id, account_id, provider, fixed origin, separate dispatch_token/control_token, evidence_keys and optional timeout_seconds (default 5, maximum 30). Files are strictly parsed and capped at 64 KiB; up to 64 connectors are accepted, and a file with none is refused unless it declares graph_connectors, checkpoint_providers, sample_providers or an arcade block.
The provider field defaults to mock_crowdstrike for old endpoint configurations; identity configuration must explicitly select mock_entra. Use independent provider-family authentication and signing keys, and independent continuation keys. Startup rejects cross-family key/token reuse. Secret values belong only in operator-managed files or a future deployment secret manager; never in example requests, git, shared output or the protected agent's environment.
seed-validation writes every generated file under its --output directory. Fixture IDs are deterministic from a public seed; credentials and keys are independently random. seed-validation publishes fixture.json only after the database commit. Database/filesystem publication is not one crash-atomic transaction; failed provisioning retains private files for diagnosis and requires a fresh seed/path for another attempt.
Administer tenants, credentials, approvers and bundles
Two connections carry administrative authority. The owner connection (MIGRATION_DATABASE_URL) provisions authority data that runtime SQL cannot change: tenants, principals, credentials, approver assignments, principal groups, the connector registry (accounts, operation mappings, canonical targets and resources) and, through manifests, context sources, inventory facts and incidents (see Provision trusted context). The restricted runtime connection (DATABASE_URL) carries bundle lifecycle changes on behalf of a current ADMIN credential read from a private file. Neither path has an HTTP route; the administration pages reach principal, credential and assignment changes through database functions, never through the owner connection.
uv run execbound tenant-create --name "Pilot"
uv run execbound principal-create --tenant <tenant> --kind ADMIN
uv run execbound credential-issue --tenant <tenant> --principal <admin> --actor <admin> --lifetime-hours 8 --output local-data/admin.token
uv run execbound person-invite --tenant <tenant> --email admin@example.com --kind ADMIN --label "First administrator" --origin https://app.example.com --output local-data/admin.code
uv run execbound principal-create --tenant <tenant> --kind HUMAN_APPROVER --actor <admin>
uv run execbound approver-assign --tenant <tenant> --approver <approver> --target <canonical target> --action endpoint.isolate --actor <admin>
uv run execbound group-create --tenant <tenant> --name triage --actor <admin>
uv run execbound tenant-assistant --tenant <tenant> --credential-file local-data/admin.token --set on --reason "Pilot"
uv run execbound group-assign --tenant <tenant> --group <group> --principal <agent> --actor <admin>
uv run execbound group-unassign --tenant <tenant> --group <group> --principal <agent> --actor <admin>
uv run execbound group-list --tenant <tenant>
uv run execbound space-create --tenant <tenant> --name "Customer A" --detail "First customer" --actor <admin>
uv run execbound space-rename --tenant <tenant> --space <space> --name "Customer A2" --actor <admin>
uv run execbound space-assign --tenant <tenant> --space <space> --principal <approver> --role HUMAN_APPROVER --actor <admin>
uv run execbound space-unassign --tenant <tenant> --space <space> --principal <approver> --actor <admin>
uv run execbound space-archive --tenant <tenant> --space <space> --reason "customer left" --actor <admin>
uv run execbound space-unarchive --tenant <tenant> --space <space> --reason "customer returned" --actor <admin>
uv run execbound space-list --tenant <tenant>
uv run execbound account-create --tenant <tenant> --provider mock_crowdstrike --actor <admin>
uv run execbound mapping-create --tenant <tenant> --account <account> --action endpoint.isolate --actor <admin>
uv run execbound target-create --tenant <tenant> --actor <admin>
uv run execbound resource-create --tenant <tenant> --account <account> --resource-type endpoint --provider-id device-01 --source <inventory source> --hostname ws01.example --role workstation --environment production --criticality 2 --actor <admin>
uv run execbound account-list --tenant <tenant>
uv run execbound mapping-list --tenant <tenant>
uv run execbound resource-list --tenant <tenant>
uv run execbound ruleset-compile --tenant <tenant> --input rules.json --output bundle.json
uv run execbound bundle-create --tenant <tenant> --credential-file local-data/admin.token --input bundle.json
uv run execbound bundle-validate --tenant <tenant> --credential-file local-data/admin.token --bundle <bundle>
uv run execbound bundle-preview --tenant <tenant> --credential-file local-data/admin.token --bundle <bundle> --window-hours 24
uv run execbound bundle-activate --tenant <tenant> --credential-file local-data/admin.token --bundle <bundle>
uv run execbound credential-revoke --tenant <tenant> --credential <credential> --actor <admin>
credential-issue writes the token once to a new file created exclusively with owner-only permissions and prints identifiers only; it refuses an existing path and removes the file again if the row is not committed. Every change is audited in the tenant chain under --actor, which must be an active ADMIN principal of that tenant; only a tenant's first ADMIN is created without an actor and audited as itself. Credential lifetimes are bounded to 90 days. Administration events are informational to offline replay: they never change a replayed decision and keep a tenant's history complete. Revoking a credential ends the sessions and continuations it backs at their next check; it never erases impact or operation history. Unassigning an approver makes pending decisions under that scope fail their server-side scope check. Owner-only permissions are POSIX modes. On Windows the creation mode only clears the read-only attribute, so every private output file this project writes — administrative tokens, --report files and monitoring source tokens — is still created exclusively but inherits the directory's access control list; write them to a directory the operator alone can read.
Principal groups are the membership a group-scoped limit rule counts over (Policy). group-create names an empty group, unique within the tenant; group-assign and group-unassign add or remove an active AGENT principal (at most 64 groups per principal) under the tenant fence, so a change is ordered against every decision; group-list shows each group with its sorted members. Membership is read inside the fence at every decision and retained on the impact row it reserves, so unassigning a principal stops its later requests charging the group while its earlier rows keep counting, and a principal assigned later shares the existing count. The audit chain keeps the history under group.created, group.assigned and group.revoked, which are informational to replay. A bundle whose rule names a group is refused at validation and at activation while the group is unknown. Admission does not repeat that check — it reads the active bundle through the execution store, which revalidates no component — so what keeps an active bundle's group scope meaningful is that nothing deletes a group: no command does, the runtime holds SELECT only on both tables, and the membership foreign key has no cascade.
Spaces divide ownership and view inside one tenant, and the tenant stays the one security boundary; nothing here is isolation. Every tenant has exactly one space named Default from the moment it exists, so a deployment that never makes a second behaves exactly as it did. space-create names one, unique within the tenant case-insensitively; space-rename changes its name and its detail line; space-archive stops new work in it, which admission refuses at the emergency stop's own check point, and space-unarchive lets it take work again — a space is never deleted, and archiving the tenant's last unarchived one is refused. space-assign and space-unassign seat a person in a space and remove them: the role narrows and never raises, so the ADMIN role is refused for a HUMAN_APPROVER principal, and the assignment that would leave the tenant with no tenant administrator — an active ADMIN principal holding no membership at all — is refused, the way executor-key-revoke refuses a tenant's last live key. space-list shows each space with its seated people. Each command writes the same event with the same payload as the console (space.created, space.renamed, space.archived, space.unarchived, space.member_assigned, space.member_unassigned), so execbound space-create and the console are indistinguishable in the chain; a space switch writes nothing, because a view change grants nothing. principal-create --space, account-create --space and approver-assign --space name the space a new agent, account or approver scope belongs to, and default to the tenant's only one. Every command's actor must be a tenant administrator: an administrator seated in spaces administers agents and scopes in their own spaces and no longer administers spaces themselves.
The connector registry is what a request resolves against, and until these commands existed only the loopback-only validation seeder could write it. account-create creates an active connector account for one provider of the action catalog; the gateway's private configuration names the account by id. mapping-create binds a catalog action to that account under an operation ref (the action name unless --ref says otherwise, unique per tenant) and takes the provider operation and schema version from the catalog, never from the caller, so a mapping can only name what the catalog binds; its context contract is the catalog's default for the action unless --contract-version names another catalog version that serves it (a catalog version that does not serve the action is INVALID_CONTRACT, and one the catalog does not hold a usage error; an operation ref or mapping version shaped like a password, key or token is INVALID_REFERENCE or INVALID_VERSION, because every plan that resolves the mapping copies them), which is how a mapping reaches endpoint-isolation.v2 (Security signals). --mapping-version (default map-v1) must match the version the bundle's mapping refs name. target-create creates an empty canonical target for approver assignments. resource-create registers one provider resource: it must be attested by an active inventory context source of the same account (context-source-create first), it is bound to --target or to a new canonical target, and it gets a provider_id alias plus an optional --hostname alias (endpoints only) valid for --alias-days (default 365) from creation, which is how an agent's provider_id or hostname selector finds it; a provider id, version or hostname alias shaped like a password, key or token is refused as INVALID_PROVIDER_ID, INVALID_VERSION or INVALID_ALIAS. The optional facts (--role or --privilege-class, --environment, --criticality) are checked against the contract domains for the resource type; they can also arrive later through context-import, which never creates resources. Mapping and resource creation take the tenant fence like group changes, so a registry change is ordered against every decision. The three *-list commands read without auditing; the creates are audited as account.created, mapping.created, target.created and resource.created, informational to replay. Deactivation and alias renewal remain owner SQL for now.
ruleset-compile writes such a file from a ruleset instead of by hand, which is the only way to author a first bundle from a deployment that has no checkout: it reads the tenant's active operation mappings, compiles the rules against the contracts those mappings name, and writes the bundle document with the ruleset carried inside it. --ref, repeatable, compiles over the named mappings only, because one bundle serves an action under one contract version and describes each Cedar entity one way: without it every active mapping is taken, and a pair that cannot share a bundle is refused at mappings.<n> with a hint to choose. Rules name the catalog action, never the operation ref; a mapping whose ref is another name for its action carries that action in the bundle, taken from its row. --signal-mappings names a JSON array of signal mapping entries to carry into the bundle, never compiled (Security signals). It opens no bundle row, activates nothing and audits nothing, because it changes nothing; it refuses to overwrite its output, refuses a tenant with no active mapping, and reports a bad rule at its field location without echoing the value. Validation and activation recompile the carried ruleset and refuse content that disagrees with it, which is what allows the compiler to sit outside the enforcement boundary. execbound/data/community/starter-rules.json, which execbound init copies out of the image beside the compose file, is a worked example, and Install uses it.
The same lifecycle is available to a signed-in ADMIN on the policy pages (/policy), which take the bundle document as pasted JSON, show validation problems by field, preview a validated candidate against the last 24 hours and require an explicit confirmation before activation. Bundle files are JSON with the BundleContent fields (cedar_schema, authorization_policies, approval_policies, mappings, limits, optional context_settings, optional quorum, optional ruleset, optional signal_mappings); tenant_id must match --tenant and bundle_id is generated when omitted. bundle-create reports invalid field locations without echoing field values, and refuses content holding text shaped like a password, key or token as Policy store refused: INVALID_TEXT at <location>., and bundle-validate and bundle-activate refuse a limit rule whose scope names an unknown group, an unknown or inactive account, an action outside the catalog or a fact no serving contract declares, at limits.<n>.<field>. bundle-activate selects the bundle for the tenant's first space and, atomically with the audit events, returns the previously selected bundle to VALIDATED only when no other space still selects it (audited as policy.bundle_deselected), so it can be activated again; a bundle another space selects may be activated too. Retiring a bundle any space selects is refused as INVALID_STATE: activate a replacement first, then retire the one it replaced. bundle-list shows each bundle's state and whether a space selects it. bundle-preview exports the tenant's bounded history as the ADMIN, verifies it and replays the candidate offline in memory; it prints the decisions within the window whose effect would change, with baseline and candidate reasons and the simulator's uncertainty markers (including UNKNOWN_GROUP_MEMBERSHIP for a candidate group rule over captures that predate recorded membership). It never activates the candidate and never touches live budgets, approvals or execution state; a preview is an explanation, not proof of hypothetical effects.
A signed-in ADMIN can also issue and revoke agent and approver credentials from /credentials (the secret appears once, on the issuing page, and is never stored; copy it to the agent or approver there) and assign or unassign approvers from /admin/approvers. Those pages call the SECURITY DEFINER functions of migration 0021_admin_functions, which verify the tenant and the acting ADMIN inside the database, refuse administrator principals and credentials and bound lifetimes to 90 days; the runtime role holds EXECUTE on them and still cannot write the tables. Creating tenants, administrators and administrator credentials, and every registry change, stays with the owner commands above.
Console role management is described in Users, groups and approval rules.
Workspace Administrators can now grant and revoke built-in human roles directly or through
groups, including Administrator access, without rewriting immutable principal kinds. Owner
commands still provision initial identities and protected administrator credentials. Current
permissions govern administrative functions; a raw historical ADMIN kind does not retain
access after managed-role revocation. A promoted Administrator's sign-in bindings and
recoverable credentials remain protected from the ordinary user-credential controls.
Give a person a password, and set up a sign-in provider
A person signs in at /login with their address and a password, with a provider button, or
with a review credential. The address and the password are two separate provisioning steps
and neither is ever typed by an administrator.
The first administrator, from the owner connection. person-invite is the bootstrap:
it finds or creates a person by address with a kind and a label, writes one set-password
code to --output exactly as credential-issue writes a token, and prints identifiers, the
address to open and the file path. An operator with no credential at all can therefore give
themselves an ADMIN person and a code. --actor is required once the tenant already has an
active administrator, exactly as principal-create requires it, so a second administrator
is always audited under an existing one. Hand over the printed open address and the code
from the file; the person opens the address, types the code and chooses a password. The code
is single use, expires (24 hours by default, --lifetime-hours between 1 and 336) and is
revoked by five wrong secrets or by the next code for that person. Never put a code in a
link: it is typed into the form, which is why the command prints an address and a file
path rather than a URL carrying the secret.
Everybody else, from the console. On a person's page under People, an administrator sets
the sign-in address and issues a set-password code, shown once beside the address to open.
Both refuse an ADMIN target: the console still cannot create or change an administrator, so
an administrator's address and code stay with person-invite. A person changes their own
password on Settings by presenting the current one; nobody can set one for somebody else.
Google. In Google Cloud, create an OAuth 2.0 client of type Web application in the
project that owns the deployment's consent screen, and give it exactly one authorised
redirect URI: <origin>/login/google/callback, for example
https://execbound.example.com/login/google/callback. No scope configuration is needed; ExecBound
asks for openid email and nothing else. Put the client id in the sign_in.google block
with the same redirect URI, and the client secret in EXECBOUND_GOOGLE_CLIENT_SECRET, never
in the file. The issuer, the authorization, token and JWKS endpoints are pinned in source
and are not configurable.
GitHub. Create an OAuth App (not a GitHub App) under the organisation or account
that owns the deployment, with the authorization callback URL
<origin>/login/github/callback. ExecBound asks for the user:email scope and nothing else,
and it reads only GET /user and GET /user/emails. Put the client id in the
sign_in.github block with the same callback URL, and the client secret in
EXECBOUND_GITHUB_CLIENT_SECRET.
Microsoft. In a Microsoft Entra tenant the deployment's operator controls, App
registrations, New registration, Accounts in any organizational directory (multi-tenant,
work and school accounts only); add a Web platform with exactly one redirect URI,
<origin>/login/microsoft/callback. Under Token configuration add the optional ID-token claims
email and xms_edov; under Certificates and secrets create a client secret and note its
expiry. ExecBound asks for openid email and nothing else. Put the client id in the
sign_in.microsoft block with the same redirect URI, and the client secret in
EXECBOUND_MICROSOFT_CLIENT_SECRET. The organizations authority, its token and key-set
endpoints and RS256 are pinned in source. The ID token's issuer must be the issuer of the
token's own tenant (tid), a personal Microsoft account's tenant is refused, the subject is
the tenant and object identifiers together (<tid>:<oid>), and the address is believed only
when xms_edov is true, Microsoft's statement that the account's tenant verified the
address's domain. Without Microsoft's publisher verification, many organizations refuse
their users' consent to a multi-tenant application, and the person meets Microsoft's "Need
admin approval" page; complete it before offering the button.
This button is not a workspace's directory. A directory pins one tenant and refuses the multi-tenant issuers, because that pin is what stops an account in another Microsoft tenant holding the same address; it decides who may enter a workspace. The Microsoft button is a personal sign-in method like Google: it proves one browser's account, it signs in only an account that linked it, and it never opens a workspace that requires sign-in through its own directory. Offering the multi-tenant button on a self-hosted deployment is its operator's own choice, made by adding the block; nothing turns it on by default.
The customer's own directory: Entra ID or Okta. This one is not a block in the
configuration file. A workspace administrator registers it from Workspace settings, so the
deployment needs only the key ring its secret is sealed under (sign_in.directory above),
and the value the customer pastes at their provider is the same for every tenant:
<origin>/login/directory/callback
Give the administrator this sequence. At Entra ID: App registrations, New registration,
Accounts in this organizational directory only; add a Web platform with exactly that
redirect URI; under Certificates and secrets create a client secret and copy its Value;
under Token configuration nothing is needed, because openid profile email offline_access is
what ExecBound asks for. The issuer is https://login.microsoftonline.com/<directory (tenant) ID>/v2.0, taken from the Overview page. At Okta: Applications, Create App Integration,
OIDC Web Application, Authorization Code with Refresh Token; put the same redirect URI in
Sign-in redirect URIs; assign the people who will use it; the issuer is the one the
authorization server shows, usually https://<your org>.okta.com/oauth2/default.
Back on Workspace settings, Register a provider takes a name, that issuer, the client id, the
client secret and the subject claim: oid for Entra ID, whose sub differs per
application, and sub for Okta. Discovery is fetched and checked while the form is open, so
a wrong issuer, an unreachable one, or a multi-tenant Entra issuer (common,
organizations, consumers) is refused there and nothing is saved. The directory must sign
its ID tokens with RS256, PS256 or ES256 (a P-256 key): Entra ID and Okta sign with
RS256 by default, and a discovery document offering none of the three is refused there too.
A registration is a draft until a workspace administrator signs in through it.
Test sign-in on the card sends the administrator through the flow and, when it works,
links their own account and marks the provider verified; only then may it be enabled.
Enabling shows the tenant's sign-in address, <origin>/login/directory/<provider id>, which
the administrator distributes. Invite the rest of the people from each person's page: the
response shows a code once, they open /invite, type it, sign in at the directory, and the
account is bound to them on the way back. A person who already signs in here another way can
instead press Link my identity on My account once the provider is enabled; only a
workspace administrator may sign in through a provider that is not enabled yet. Disable
revokes every directory session in the tenant at once and keeps the links, so enabling again
restores them. Passwords and credentials keep working beside a directory; refusing them once
everybody is linked is not built.
Who configures it. Registering, the test sign-in, enabling and disabling take
workspace-wide access.manage, which a workspace administrator holds and an administrator
narrowed to spaces does not. The provider signs people in to the whole workspace, so a
narrowed administrator sees the card and the provider's state and is offered none of its
controls; the console answers them 403 and the database refuses them as well (decided 8
October 2026). They still invite and link the people of their own spaces once a workspace
administrator has enabled it.
Deprovisioning is a poll, not a push. Whenever a directory session's renewal is due, which holds for the whole two days including the last twelve hours when it can no longer be extended, ExecBound redeems its refresh token; an account disabled or deleted at the directory loses the console within about seven and a half minutes of continuous use (about twelve and a half at most), and at the first request after a pause otherwise. In a workspace that requires its directory, that first request after more than fifteen minutes away asks the directory before the session loads, so the session goes on while the account is good and ends when it is not. While the directory is unreachable the session stands for fifteen minutes past its last verification and is refused after that, and nothing is revoked, so an outage that resolves does not force a tenant-wide sign-in. To end somebody's access now, deactivate them on People, which is immediate.
A Google, GitHub or Microsoft sign-in is identified by the provider's stable subject alone and
creates nothing: a subject no account has linked is refused, even when its address matches an
account. A provider's verified address (Google's email_verified, GitHub's address that is
both primary and verified, Microsoft's xms_edov) is believed for two things only: to start a
new account through signup, where a plugin offers it, and to link the provider from a
signed-in account whose own verified address equals it, after a recent sign-in and the second
factor where one is enabled. No claim a provider sends changes a role, and a changed address at
the provider neither locks anybody out nor lets anybody in. A deployment without a block shows
no button for that provider and answers 404 on its routes.
When a person leaves
An approver who leaves is deactivated on their People page (/people/{principal_id}, Deactivate this person, reason required). One transaction sets the principal inactive, revokes every live credential and every session, and appends principal.deactivated with the reason and the counts beside one credential.revoked and one human_session.revoked per row, so the person's next request of any kind is refused and the chain says who ended their access and why. Scopes are kept; Reactivate this person restores them without any credential, which is then issued from the same page. A person who has since rejoined through a teammate invitation is the exception: their old entry is retired for good and refuses reactivation (PRINCIPAL_REPLACED), and their access lives on the new entry (see Invite a teammate). An administrator is not deactivated from the console: admin_deactivate_principal refuses an ADMIN target and the actor, so a departing administrator is handled with owner tooling, and their sessions can still be ended from their People page in the meantime. Nothing is deleted; the audit chain refers to the principal forever.
Why was my agent refused
Open Agents, then the agent. Limits lists every rule of the active bundle that bears on it: a rule on an action the agent's space is offered (the action each mapping serves, whatever its operation is named, as admission reads it), or on an action the agent has already been charged under. A rule counted for the agent, for one of its groups or for the whole tenant is shown as "n of m" in its rolling window and counted exactly as admission counts: when a figure has reached its ceiling, the next request needs approval or is denied, as the line under the rule says. A rule scoped to a connector account, a fact or a space is listed with its ceiling and window and no count, marked "counted per request". Recent activity shows the states of the agent's last twenty requests, and All of this agent's activity opens them with their decisions and obligations. A credential listed as revoked or expired is replaced from the same page; a deactivated agent has to be reactivated before it can be issued one. If no bundle is active, the page says so, and every request is refused until one is.
From discovery to governance
An agent a monitoring connection lists appears on Agents as discovered only. Open it. If an agent principal for it already exists, bind the two with Bind; otherwise Govern this agent creates the principal, labelled with the agent's display name, binds it and issues its first credential in one step, and the next page shows the secret once beside the install line and the MCP line for this gateway. Put the credential in the agent's host; from its first execution through ExecBound, the agent's reach on that binding reads verified. Bind only when you know the principal is that agent: a display name that looks right is not evidence. Unbind takes a reason, stops attributing the principal's executions to the agent at once and keeps the binding's history. python -m execbound.monitoring.commands link-agent and unlink-agent make the same statements from the command line through the same functions and audit events.
Register and cut off executor keys
An externally executed operation settles from a report its executor signs, and the key that report is verified against is owner-provisioned authority data like an approver assignment. ExecBound holds only the public half, so read access to executor_keys cannot forge the attested tier and no command has a private key to echo; executor-key-list does print the registered public key and its algorithm, which is the point of it.
A tenant serving the Arcade logic extension needs one before the bundle can be activated, not merely before the first hook arrives, and it should be held where the control point cannot reach it: the extension holds a usable report secret and never a key, so the tenant's own administrator is the only party that can answer what a control point said about a row. Cutting that key off as compromised no longer freezes such a row — the platform's bare word still settles it and the recovery run still frees its target — and it does make the correction of that settlement permanently impossible for every grant issued before the revocation.
uv run execbound executor-key-register --tenant <tenant> --key-id soar-2026 --public-key <64 hex characters> --actor <admin>
uv run execbound executor-key-register --tenant <tenant> --key-id tines-gate-2026 --algorithm ES256 --public-key-file es256-public.pem --actor <admin>
uv run execbound executor-key-rotate --tenant <tenant> --key-id soar-2026 --new-key-id soar-2027 --public-key <64 hex characters> --actor <admin>
uv run execbound executor-key-revoke --tenant <tenant> --key-id soar-2026 --actor <admin>
uv run execbound executor-key-compromise --tenant <tenant> --key-id soar-2026 --actor <admin>
uv run execbound executor-key-list --tenant <tenant>
A bundle mapping that declares an external executor is refused, and an active one fails admission closed, while the tenant has no unrevoked key, so register a key before activating such a bundle. Registration refuses a taken key id, material already live for the tenant, material the tenant has already recorded as compromised, any encoding nobody holds the private half of, the executor protocol's published test keys, whose private halves anyone holding its vectors has, and a key id shaped like a password, key or token, as a rotation does its new id (the old id, a revocation's and a compromise report's are never refused for their shape, because each names a key that already exists); uppercase hex is normalized, and every refusal is INVALID_KEY and says nothing about which rows the tenant holds. The four writers take an ADMIN --actor and are audited in the tenant chain; executor-key-list takes no actor, writes and audits nothing, and shows each key with its registered public key, algorithm, creation and revocation times and revocation reason, and "published": true on a published test key.
executor-key-register and executor-key-rotate also take --algorithm ED25519 (the default) or --algorithm ES256, and either --public-key or --public-key-file (mutually exclusive; an unreadable file is a clean INVALID_KEY refusal rather than a crash, and trailing whitespace such as a key file's final newline is stripped before the shape check runs). An Ed25519 key is 64 lowercase hex characters, as it has always been. An ES256 key (for a platform that cannot sign Ed25519, such as Tines' JWT_SIGN) is read as an SPKI PEM (-----BEGIN PUBLIC KEY-----), a P-256 JSON Web Key ({"kty": "EC", "crv": "P-256", "x": ..., "y": ...}), or 33 bytes of compressed SEC1 hex, and whichever form arrives is normalized to that compressed hex before it is stored, so a key registered from a PEM and the same key registered from a JWK compare equal. Registration refuses a point not on the P-256 curve or on any other curve, private key material in any spelling, a JWK carrying a field beyond its public ones, and the ordinary refusals every key gets (a taken id, material already live for the tenant, material already recorded compromised). A key attests only reports in its own scheme: an Ed25519 key never verifies a JWS and an ES256 key never verifies the Ed25519 envelope, and the scheme comes from the registered row rather than from anything a report says about itself.
Retirement and compromise are different acts, and the command names say which. executor-key-revoke and executor-key-rotate record RETIRED: a proof for a grant issued while the key was current still verifies, which is what makes a scheduled rotation safe. executor-key-revoke also refuses to retire the tenant's last live key while an active bundle declares an external executor, and a published test key does not count as live; executor-key-rotate carries no such guard and needs none, because it inserts its replacement in the same transaction, so a live key always remains. executor-key-compromise records COMPROMISED, which the verifier reads retroactively: the key attests nothing, whatever instant the grant was issued at. It may be recorded over a row already stamped RETIRED, because incident response rotates or revokes on suspicion and confirms the theft afterwards, and it preserves the original revoked_at — the audit chain carries when the compromise was reported. It reaches every id of the tenant carrying that public key, since a second id spelling the same key would otherwise go on attesting with the stolen private half.
Before running it, know what it costs. It is the right action and it is not free:
- It may leave the ring empty, deliberately: refusing to cut off a stolen key because it is the last one would be a refusal to perform the security action. Admission then refuses new grants for that tenant with
INVALID_BUNDLE(409) until a replacement is registered. - The replacement attests none of the cut-off key's grants. A key speaks only for grants it covered.
- A grant only the cut-off key could have attested has no attested settlement. A report carrying a signed record, the administrator's settlement and a replacement key are all refused at the same verifier. A grant nobody reports keeps its impact charged and its canonical claim held indefinitely: the recovery sweep leaves the row
INDETERMINATE, it appears in the uncertain queue, and nothing releases either. A grant whose holder still has its report secret is settled by the executor's unsigned report, its impact confirmed, and the correction of that settlement is then unreachable in that tenant (threat model R13). - Outstanding grants are not swept, because sweeping closes nothing — an uncertain grant is as reportable as a committed one — and a grant issued while a second, uncompromised key was also current is still perfectly attestable by that key.
One availability answer to recognize. The tenant's ring is read whole and constructed whole: one malformed or forgeable row anywhere in it fails the whole ring rather than being skipped, because a key nobody could have signed with is not evidence that the rest of the ring is what an administrator registered. It fails closed, and both registration and the key model refuse such a row, so only a row predating those guards or written over the owner connection can reach it. Both the outcome route and the console's settlement control answer INVALID_KEY_RING (409) rather than a generic 503, because nothing changes until an administrator repairs the ring: run executor-key-list and replace the row that will not load, instead of retrying. A published test key registered before registration refused it is not such a row: it is a real key, so the ring loads, and it attests nothing, so a report or an administrator's settlement signed with it is refused, the ring's other keys go on attesting, and a ring whose only live keys are published refuses new grants with INVALID_BUNDLE. It counts as no live key anywhere: a bundle declaring an external executor is not validated on one, and executor-key-revoke will not retire the tenant's last key of its own beside one while such a bundle is active. executor-key-list shows the row with "published": true. Register a key of your own, then revoke that row.
Configure a provider's journal key for the Gate
A checkpoint settles at the PROVIDER_VERIFIED tier when the provider itself signs a journal of the exact command, under a key the operator configures for that Gate account. It is a private configuration block, checkpoint_providers, alongside preconditions:
{
"checkpoint_providers": [
{
"tenant_id": "<tenant UUID>",
"account_id": "<the Gate account, configured under no connector>",
"provider": "mock_crowdstrike",
"evidence_keys": {"journal-v1": "<the provider's own signing key, encoded as hex>"}
}
]
}
It holds no URL, no dispatch token and no control token, because the gateway never calls this provider: the field says so on the wire, evidence_keys, the same shape a connector's own field takes for the journals ExecBound does verify on the dispatch path. An account named here and also under connectors or graph_connectors refuses to load, as does a duplicate entry, because a Gate account has no connector by construction and a provider named twice would leave two rings claiming one account. A journal key must be independent of every connector's evidence keys and of the continuation keys, checked when the runtime is built and again on every key reload, so the same secret can never verify two different things. Nor may it, any connector's evidence key or a sample key be a published test key: chapter 1's test_verification_key_hex, chapter 2's conformance-journal, the journal key the vectors' executor signs its refusals with, and the key regression fixtures are re-signed under. Anyone holding the protocol's vectors or ExecBound's source can sign a journal under each, so a file configuring one refuses to load, and a reload that brings one into a connector's ring is refused and leaves the running keys untouched. Each key is compared as HMAC-SHA256 uses it, so the same key with zero bytes appended is refused too, and the independence rule above is asked the same way. Changing this block needs a restart. It is read once when the runtime is built and carries no rotation of its own: a key_reload_seconds reload rotates active_key_id, continuation_keys and a connector's own evidence_keys, but a file that changes checkpoint_providers and nothing else still reports refused on that check and leaves the running keys untouched, exactly as a changed preconditions or arcade block does. See Surfaces and the checkpoint guide.
Provision trusted context
Inventory facts and incidents enter the registry through manifests. An adapter, which has network access and no database authority, writes an execbound.context-manifest.v1 file; context-import, which has the owner connection and no network access, validates it against the contract catalog and the tenant's sources and applies it in one audited transaction under --actor. Create a source first and give it the writer labels that may write through it; the labels stay fixed for the source's life, so a changed label set is a new source.
uv run execbound context-source-create --tenant <tenant> --account <account> --kind inventory --writer inventory_sync --actor <admin>
uv run execbound context-source-create --tenant <tenant> --account <account> --kind incident --writer soc_analyst --actor <admin>
uv run execbound context-import --tenant <tenant> --manifest inventory.json --actor <admin> --report inventory-report.json
uv run execbound context-import --tenant <tenant> --manifest incidents.json --actor <admin> --strict
uv run execbound context-source-deactivate --tenant <tenant> --source <source> --actor <admin>
context-import prints the manifest digest and counts (entries, applied, unchanged, unknown_resources, facts_written, facts_retracted, resources_deactivated, incidents_created, incidents_updated), and exits 2 with Provisioning refused: <code> at entry <n>. and no write when anything is refused: a fact outside the contract catalog, a writer the source does not allow, an observation that goes backwards, a reused version with different content, a future timestamp, or with --strict a resource the registry does not hold. An entry holding text shaped like a password, key or token anywhere is INVALID_MANIFEST at that entry, and context-source-create refuses such a writer label as INVALID_WRITER. --report writes the per-entry outcomes to a new private file, created before the import: a path that already exists, or whose directory is missing or not writable, is refused as REPORT_UNAVAILABLE with nothing imported, and a refused import removes the file again. The outcomes are written once the import has committed, so a write that fails then, on a full disk for example, is not a refusal: the command prints the digest and counts, removes the partial file and exits 2 with Provisioning imported the manifest, but the report was not written: REPORT_UNAVAILABLE.. The import stands and is audited, and its per-entry outcomes are not kept anywhere else. Re-importing the same manifest is a no-op, which is also why it cannot recover them: every entry reads unchanged. The context contracts guide states the manifest shape and every rule.
Operational notes:
- Schedule adapter then importer with cron, a systemd timer or a container job, one tenant and one manifest per invocation, and keep the manifest files: each import's audit event carries the manifest's SHA-256, so a retained file is the evidence for what was applied.
resources.versionis the provider's expected version, frozen into every plan and checked by the provider at dispatch. Let an adapter stateprovider_versiononly when it reads that version from the provider itself; a wrong value makes every dispatch for that resource fail closed asSTALE_AUTHORIZATION, and an absent one leaves the retained version alone.- A source with a label the tenant's bundle does not permit for that contract makes its facts fail resolution until a
context_settingsentry admits the label; the importer accepts the manifest, resolution refuses the facts. - Deactivating a source makes every fact it attests fail resolution at once; use it when an adapter's credentials are compromised or the system is retired. Retraction and
active: falselikewise only remove evidence; nothing an adapter sends can widen authority. - The importer never creates resources, aliases or canonical identities: an unknown resource is reported, and onboarding it stays owner registry work.
Security signals are the one kind of trusted context that does not arrive through a manifest: a registered producer posts envelopes to POST /v1/signals with its own sg1. token, and they count only through a bundle's signal_mappings. The owner commands signal-source-create, signal-source-rotate, signal-source-revoke and signal-source-list register, rotate, revoke and list producers, audited under --actor; the security signals guide is the runbook, including recovery from an interrupted registration and what to do when a token expires.
Start and stop
For a new machine use the README's demo section. scripts/local_setup.py provisions one PostgreSQL setup: Docker Compose, or native PostgreSQL 17 with --postgres-bin <directory>. Both listen on the same loopback port (POSTGRES_PORT) but keep separate storage, so switching implementations does not copy data. Existing passwords/roles are checked rather than silently reset. When Docker Compose cannot start the database, the script prints the last twenty lines Compose wrote, every value from .env shown as [redacted].
The complete operator workflow is:
uv run execbound seed-validation --seed operator-demo-1 --output local-data/operator-demo-1
uv run python scripts/validation_demo.py serve --private local-data/operator-demo-1
Run immediately after seeding. serve owns gateway 8100, endpoint 8101 and identity 8102 by default; change these with the seed command's port flags before provisioning. It refuses occupied ports, stops only its own children, and exits on Ctrl+C. The automated run variant additionally drives the demonstration and requires a new --output directory. Neither stops PostgreSQL.
To stop Docker's database while retaining data:
docker compose stop db
To stop a native instance while retaining data, with the binaries --postgres-bin named:
<PostgreSQL 17 bin>/pg_ctl -D local-data/postgres -m fast -w stop
For a foundation-only smoke service, uv run uvicorn execbound.app:create_app --factory --host 127.0.0.1 --port 8000 uses DATABASE_URL. It has no execution/MCP/human routes unless EXECBOUND_RUNTIME_CONFIG is supplied. Prefer the fixture harness when you need the complete runtime; it sets the separate provider configuration and process lifetimes coherently.
Health and capacity
/health/live checks that the gateway can respond. /health/ready checks PostgreSQL schema revision and restricted role/table privileges, and that every dormant object holds exactly what the loaded plugins claim; it does not verify provider health, active policy, inventory freshness or every configured account. A ready process may legitimately reject a particular action.
HTTP requests are limited to 32 concurrent requests per gateway process. The runtime admits at most as many concurrent calls as the database pool can serve, 13 by default (five connections plus eight overflow); an explicit higher value is refused at construction. Action bodies are capped at 16 KiB, MCP envelopes at 24 KiB, and human forms at 16 KiB; bounded reads time out after five seconds. Database pools default to five connections with eight overflow, a five-second checkout/connect timeout, and transaction-local ten-second statement/five-second lock timeouts. These are process limits, not a deployed distributed rate limiter.
Admission throughput under the tenant fence was measured with scripts/admission_benchmark.py on September 15, 2026, on a 16-core workstation with PostgreSQL 17 in a local container, using denied admissions so every request takes the full serialized path without a provider effect. One tenant sustained about 40 admissions per second at every concurrency from 1 to 16, with a service time of about 24 ms per admission and median latency growing linearly with concurrency (26 ms at 1 caller, 312 ms at 16) because the fence serializes a tenant's admissions by design. Two tenants at 16 callers reached about 64 per second. At 16 callers a few requests hit the five-second connection pool timeout and returned 503, because the runtime's cap was then 16 against a pool of 13; the runtime now defaults to the pool capacity and refuses a higher value, and a regression test runs it at that cap without a timeout. Forty admissions per second per tenant is roughly 140,000 per hour, far above the load an early deployment's agents produce, so no finer-grained locking is planned. Revisit when a tenant needs more than ten admissions per second sustained or its 95th-percentile latency at expected concurrency exceeds one second; the benchmark is the measurement to rerun first.
Each gateway process also applies fixed-window request limits before parsing, lookup or admission: 300 requests per 60 seconds per authenticated agent credential across HTTP and MCP, 10 sign-in attempts per 60 seconds per credential selector, and 1,000 requests per 60 seconds per direct peer address on the agent and sign-in routes. A refusal returns 429 with the stable code RATE_LIMITED (the sign-in form returns a plain Too many attempts) and a Retry-After header. It happens before any database write, so it consumes no impact, approval or audit record. The counters live in process memory, are bounded in size and reset when the process restarts. Behind a reverse proxy every caller shares one peer address, so size client_requests in the configuration limits object (agent_requests, agent_window_seconds, login_attempts, login_window_seconds, client_requests, client_window_seconds, token_requests, token_window_seconds, max_tracked_keys) for the proxy. A personal access token on the console API is limited to 300 requests per 60 seconds per token (token_requests, token_window_seconds), counted once the token is authenticated as an agent key is, and also meets the per-peer limit; the console API answers a refusal 429 TOO_MANY_REQUESTS with a Retry-After header. /v1/me and the health routes are covered only by the concurrency limit.
Provider transport disables retries, redirects and ambient proxy settings. Only explicit fixed origins are used; HTTPS is verified, and cleartext is limited to literal loopback. Remote PostgreSQL URLs require sslmode=verify-full and an explicit trusted CA path. Do not weaken these checks to get a demonstration running.
Stop a tenant during an incident
- As a current workspace administrator, run
uv run execbound tenant-stop --tenant <id> --credential-file <file> --reason "<why>". Activation takes the tenant fence, so no in-flight admission slips past it; the command prints the stop identifier, and the audit event records how many executions wereDISPATCH_COMMITTEDat that instant. - While stopped, agents receive 423
TENANT_STOPPEDfor new requests (a retry of a pending approval returns its unchanged view withtenant_stopped: true), approvers see a banner and no decide form, and no dispatch attempt starts. Operations already sent to a provider still settle: record outcomes and reconcile as usual. - Reconcile every
DISPATCH_COMMITTEDexecution listed on Activity deliberately. A stop cannot recall a send that already left, and a claim refused at dispatch stays committed and unattempted until provider evidence settles it. - Resume with
tenant-resumeand a reason. Nothing halted is retried automatically: agents resend, approvers decide, and an expired approval needs a fresh plan.tenant-statusshows the current state.
The stop is per tenant; stop the process to halt every tenant. It never refunds impact, releases a claim, consumes or expires an approval, or changes an execution row, and replay reports any decision recorded inside a stop period.
Who may stop and resume. A stop halts every space of the tenant, so only a workspace administrator activates or releases one: an administrator who holds workspace-wide access.manage, which an administrator seated in spaces does not (decided 8 October 2026). A narrowed administrator's tenant-stop or tenant-resume is refused INVALID_IDENTITY, the answer an approver gets, and audited as execution.rejected; in particular they cannot lift a stop a workspace administrator set. They still see the stop: tenant-status, the console's banner and Workspace settings show it to them as to anyone.
Onboard a tenant in observe-only mode
A new tenant can send live agent requests through the kernel without enforcement and compare the recorded decisions with what its existing path did:
uv run execbound tenant-mode --tenant <tenant> --credential-file local-data/admin.token --set OBSERVE --reason "Onboarding trial"
uv run execbound observe-report --tenant <tenant> --credential-file local-data/admin.token --since-hours 24
uv run execbound bundle-preview --tenant <tenant> --credential-file local-data/admin.token --bundle <candidate>
uv run execbound tenant-mode --tenant <tenant> --credential-file local-data/admin.token --set ENFORCE --reason "Go live"
The mode is one value for every space, so only a workspace administrator changes it, with tenant-mode or on Workspace settings; an administrator seated in spaces reads it and is refused the change (INVALID_IDENTITY), as they are refused tenant-approval-deadline (decided 8 October 2026). observe-report counts the observed decisions of every space, so it is a workspace administrator's read too, and an administrator seated in spaces is refused it with the same code.
While the tenant observes, every request is authenticated, resolved and evaluated as usual and answers state: OBSERVED with an observed object (would-be effect, reasons, obligations, mode); nothing is dispatched, approved, reserved or claimed, and the emergency stop still refuses admission. To observe one newly onboarded action in an enforcing tenant, set observe_only: true on that mapping in the bundle content instead. observe-report counts observed decisions by effect, action and reason and lists every limit-driven outcome, which relied on the assumed-executed projection of earlier observed decisions rather than on enforced impact; bundle-preview replays observed decisions under a candidate bundle like enforced ones. Human pages show a mode notice and observed rows without forms, and tenant-status shows the mode. Switching to ENFORCE is audited and changes nothing retroactively: observed rows stay observed and the next request is enforced.
Invite a teammate
A member who rejoins gets a new entry. Accepting a new invitation after removal makes a fresh principal with the new invitation's roles and moves the account's membership to it; the old entry is marked replaced (principals.replaced_by) and can never be reactivated. The People page shows it as "Rejoined as a new entry" with a link to the new one, and a Reactivate attempt from the console or its API is refused PRINCIPAL_REPLACED ("This person rejoined through an invitation and now has a new entry in this workspace. Manage their access there."). Manage the person's access on the new entry.
An administrator with access.manage invites a teammate from the People page, one address, one role and one Space scope at a time; there is no CLI or owner-tooling path, because inviting is an ordinary administered action a workspace already has authority over. The console's create and resend requests each make one best-effort delivery attempt themselves, as a background task after their own transaction commits, so an administrator usually sees "Invitation sent" without waiting on the schedule at all, on a first send or a retry alike; that attempt uses the exact same lease the scheduled drain below would use next, so it never double-sends.
The schedule reaches every workspace, including one no jobs token names. workspace_invitation_due is tenant-agnostic on its own: a cross-tenant SELECT policy granted only to the role that owns every definer function in migration 0059 (never to the runtime role) lists due messages across every tenant, so every workspace is drained alike. invitations-run therefore needs no identity, no credential file and no per-tenant configuration:
uv run execbound invitations-run --limit 50
Before it lists anything due, the run checks that owner_drain is still what it must be: a SELECT policy on workspace_invitation_messages naming exactly the role that owns workspace_invitation_due, never the runtime role. check_runtime proves tables, grants, columns and functions but not policies, so this closes the one gap it leaves: a later migration that recreated the messages table without that policy, or a migration applied under a role other than the function owner, would otherwise have the drain silently deliver nothing while a local superuser owner kept seeing every row. A missing or malformed policy refuses the whole run with a fixed code (UNSAFE_DRAIN_POLICY) at exit 2, rather than delivering nothing without saying why.
It leases, rekeys and sends each due message inside that one message's own tenant fence, in its own transaction, and settles it by one terminal rule: an INVALID_STATE (the invitation moved out from under the send, by acceptance or revocation) or INVALID_ADDRESS (a stored address the mail layer itself would refuse) is terminal on the first attempt, never spent through retries, and never stalls a later tenant's message in the same pass. A database fault while settling one message is caught the same way and counts that message failed rather than ending the run for every other tenant still due; the row itself is left exactly as its lease set it and is retried once that lease expires. A fault the CLI itself catches, including one from _due before anything is even listed, answers a fixed sentence at exit 2 rather than a traceback. The jobs service runs it every two minutes where the runtime configuration has a mail block; elsewhere schedule it as its own entry, never chained onto another command with &&, because execbound.boot execs its target with no shell.
Structured logs and security event export
GET /health/ready names what is running (version, commit, and in a process that serves the console, console, the edition the packaged console build names) so a trial record or an incident note can cite it; the commit is the image's EXECBOUND_COMMIT build argument or, where a platform injects one, its RENDER_GIT_COMMIT, and the edition is read from the console build the package carries, which an image's CONSOLE_EDITION build argument chose; neither comes from configuration. Every MCP tool call is logged as mcp.call (tool, protocol revision, client name and version).
Every process writes one JSON object per line to standard error: a closed event catalog (EVENTS in src/execbound/logging_schema.py; among them request.completed, execute.completed, status.completed, actions.listed, approval.decided, dispatch.attempted, outcome.recorded, reconciliation.requested, authentication.refused, stop.activated, stop.released, mode.changed, keys.reloaded, mock.reprovisioned, monitor.intake, monitor.processed, monitor.delivery_refused, signal.intake, signal.refused, notifications.delivered, monitor.delivered, export.completed, command.completed, dependency.unavailable, log.suppressed, mcp.call, jobs.started, jobs.stopped, job.completed, job.failed, job.timed_out, job.waiting, job.held_elsewhere, job.skipped, sign_in.refused, password.refused, trusted_browser.refused, directory.refused, directory.recheck, plugins.loaded, plugins.refused, plugins.sign_in_link_failed, plugins.recovery_link_failed, plugins.feature_failed, invitation.created, invitation.sent, invitation.refused, invitation.accepted, samples.installed, samples.removed, samples.refreshed and samples.refused) with fixed fields of identifiers, states, codes, statuses, counts and durations, and no message text. The two security signal events (Security signals) never carry the token or the envelope: signal.intake carries the answer's status and the source id, and signal.refused, logged at WARNING, carries the refusal code and, once the writer has found the source the token names, that source id. Every intake request also logs request.completed with the surface signal, and a credential refusal also logs authentication.refused with the surface signal and its reason, which is where the reason appears; it is counted on the signal authentication-failure surface too. A request a request limit refused is logged by request.completed alone, with its 429 and surface, and nothing else is written for it, so a flood of refusals is not doubled in the log. export.completed is one line per destination for each export-events run, after the run's own transactions: the destination's id, the state and the last error (code) its cursor holds afterwards, and count, the events that run sent, at WARNING while the cursor is retrying or FAILED. notifications.delivered is one line per approval notification delivery attempt, once the attempt is settled: the approval and destination ids, the state it left the notification in (DELIVERED, PENDING to be retried, or FAILED), its attempts and the attempt's code, at WARNING unless it was delivered; a notification settled without an attempt, such as one whose approval closed, writes none. monitor.delivered is the same for one finding delivery: the finding and destination ids, the state, its attempts and the attempt's code, at WARNING unless it was delivered. Unlike an approval notification, a finding delivery closed without a send writes one too, at INFO for DESTINATION_UNKNOWN, CLASS_OFF and FINDING_ACKNOWLEDGED and at WARNING for DELIVERY_UNCERTAIN and a RATE_LIMITED give-up. monitor.delivery_refused, at ERROR, is one process --deliver pass that evaluated and sent nothing because its runtime configuration did not read: the command, the code and, under --config, the workspace. None of them carries a destination's URL or secret. The sample target events carry the tenant and the principal: an install, a removal and a refresh the resulting state, each logged only when it wrote rows, so a call that found nothing to add, remove or refresh logs nothing, and a refusal its code and, for a fault, a reason that is the fault's class name, joined by the fixed code a store's own error carries. Four of the five plugin events carry plugin, a first-party plugin's own name (for example sample_plugin) and never its configuration; plugins.refused carries it only when the refusal concerns one plugin whose name is valid, and plugins.sign_in_link_failed means the sign-in page hid that plugin's link because its label raised or was not a short line of text, which code tells apart. plugins.recovery_link_failed means the same for a plugin's recovery link, the sign-in page's way to reset a forgotten password, and its code tells the same two cases apart (PLUGIN_FAILED for a label that raised, INVALID_RECOVERY_LINK for one that answered something else). The fifth, plugins.feature_failed, logged at ERROR, carries feature, a session feature's registered name, and nothing of the exception: that feature's check raised, so it was withheld from the session, which was still answered. Every request carries request_id (the X-Request-Id header when it is a UUID, otherwise a fresh one, echoed on the response). A record that carries a field outside the schema, an event outside the catalog or text matching the private patterns the replay export refuses (bearer tokens, credential and source token shapes, database URLs) is replaced by log.suppressed with a reason code; a formatter failure writes log.suppressed too, and nothing in logging can change a response or a transaction. Collect standard error with the platform's log shipper; there is no in-process log shipping.
The audit chain and authentication failure summaries export to security-events destinations in the private configuration: uv run execbound export-events --tenant <id> --credential-file <admin file> [--limit 100] reads events after each destination's cursor in sequence order, verifies the chain segment, posts one signed batch of the sanitized replay representation plus the authentication failure windows recorded since the last run, and advances the cursor only on a 200 or 204. Each event's kind and payload fields are listed in the audit event reference. Any other outcome records an attempt with exponential backoff; after five attempts the cursor is FAILED and stays where it was until execbound export-resume --tenant <id> --credential-file <admin file> --destination <id>. A batch is identified by its last event hash, so a receiver that stores the identifier can drop a repeated batch after a lost acknowledgement; an event whose payload trips the replay secret check fails the cursor at that event with PAYLOAD_REFUSED and exports nothing past it. export-resume reads that event again, so such a destination stays failed. Text an agent, an Arcade control point or the assistant's model writes is screened before the chain carries it (threat model T7.6), and so is text a person or the owner types: a reason, a name or label, a key id, a registry or manifest entry, an email address's domain or a policy bundle shaped like a password, key or token is refused where it is written with that boundary's existing code, never quoting the value (T7.7); a command prints the code, and the space commands print INVALID_NAME for a reason, the code spaces._reason always raised. An identity provider's subject, at sign-in or when an account is linked (an administrator's link by hand included, since it must equal what the provider sends), and an issuer a directory's own discovery document confirms at registration are never refused for their shape: the event carries WITHHELD beside subject_digest or issuer_digest, an unkeyed SHA-256 of the exact value, which matches events about the same person or directory across tenants and exports without carrying the value; it gives correlation, not secrecy, since a short value can be guessed offline. A receiver tells a withheld value by the subject_digest or issuer_digest key, never by the literal WITHHELD, which a plain value may spell. Events already in the chain, values already stored that new plans and events copy, the assistant's model, and a destination already failed are beyond those screens (threat model R34), and the replay export refuses such an event as well. execbound export-status shows state, lag (head sequence minus the cursor), attempts and error codes per destination. Schedule the export like the recovery run; readiness is unchanged by a stuck export, which is an operator alert, and each run's export.completed log line for a destination is at WARNING while its cursor is retrying or failed.
The jobs service
These commands have to run on a schedule or a deployment quietly stops doing part of its job: recover settles committed but unsettled operations, notify delivers queued approval notifications, and monitor-process evaluates monitoring observations into findings, sends them to the destinations configured for findings and removes the directory audit observations no rule reads any more, without which a connection's observation bound fills (Scout); where a mail relay is configured, invitations-run retries teammate invitations too. A one-host installation runs them as one container of the same image, and any other deployment schedules the same commands itself (Deployment):
docker compose logs jobs # what it has run
docker compose up -d --scale jobs=2
execbound jobs runs recover --all-tenants every fifteen minutes, monitor-process (python -m execbound.monitoring.commands process --config <state>/monitor.json --limit 50) every five, audit-verify --all-tenants every hour, adding --checkpoints /srv/private/checkpoints where that directory exists (--checkpoints on execbound jobs; Verify the audit chain on a schedule), notify --all-tenants every fifteen seconds where the runtime configuration names an approval destination (webhook, email, slack or teams), invitations-run every two minutes where it has a mail block, weekly-summary-run every hour where that block also sets weekly_summary: true, and collect-due --all-tenants --key-files <state>/key-files every five minutes on every installation, since the synthetic directory needs no key (Scout), adding --collector-key and --platform-app where the collector directory (--collector, /srv/collector) holds the collector's key (collector-private.json, collector-previous.json) or the platform app's credential (scout-app.json) (Tines monitoring), each as a subprocess with exactly the arguments this document gives for running it by hand. A scheduled monitor-process runs with --deliver, and the jobs service passes it wherever it has a runtime configuration that reads (Send monitoring findings to destinations). The service decides whether to schedule notify, invitations-run and weekly-summary-run, and what collect-due is given, once, when it starts, so after adding the first approval destination, the mail block, the collector's key or the platform app's credential, restart the jobs service as well as the gateway. It decides monitor-process's --deliver at the same moment, and its jobs.started line says which (deliver). A runtime configuration that does not read when the service starts gets no --deliver until the service restarts, even once the file is fixed, so restart the jobs service after fixing it. One that breaks after the service starts stops no evaluation: each pass reads the file again, so every pass evaluates, then fails, logging monitor.delivery_refused, until the file reads again. Jobs run side by side: each due job is started without waiting for the others, so a recover pass that runs for minutes never holds a notification back, and a job still running when its next interval comes is not started a second time for that interval. recover, notify, collect-due and audit-verify hold no credential; Recover an uncertain execution and Notify approvers of pending approvals say which workspaces each reaches (schedule.py). monitor-process alone runs as a credential, the jobs administrator execbound quickstart issued, read from /srv/state/monitor.json. Quickstart also writes that token into /srv/state/jobs.json, mapped to the tenant, and neither recover nor notify reads it: the service runs them only once jobs.json, quickstart's mark, exists, and monitor-process only once monitor.json does. Before then the service says so once a cycle and runs nothing, because the installation is mid-flight rather than broken. A job that refuses is that job's exit code and log line, never the service's: the others still run, and so does the next interval.
Each job runs once per interval, whichever replica gets there first. The interval is a tick, floor(now / interval), a number every replica computes identically from the clock. A worker takes pg_try_advisory_lock(job, tick) and holds it for the rest of that tick rather than releasing it when the run ends, so a second replica finds the tick taken and runs nothing. Releasing at the end of the run would prevent two simultaneous runs and would not prevent the second replica running the same interval a second later. The cost is one idle connection per job, which is why the jobs service wants a direct PostgreSQL connection rather than a transaction-mode pooler, where an advisory lock does not survive the statement that took it.
A replica that dies drops its connections and its ticks with them, and another replica takes the next one. That is the intended behaviour: an interval must not go unrun because the process that owned it is gone.
The lock reaches two jobs services and nothing else. A job you run by hand takes no lock, so docker compose run --rm gateway execbound recover --all-tenants will run beside a scheduled one. That is deliberate — an operator asking for a sweep should get one — but it means the guarantee is about replicas of this service, not about every way the command can be invoked.
The jobs administrator's credential expires like any other, and only monitor-process still uses it. Reissue it with credential-issue, write the new token into state/monitor.json as admin_token, and restart the service; state/jobs.json only has to exist.
Verify the audit chain on a schedule
A chain cannot record its own alteration, so the only way to find an altered, reordered, deleted or truncated event is to verify the chain again. The jobs service does that every hour. By hand, the same pass is:
docker compose run --rm jobs execbound audit-verify --all-tenants
Add --checkpoints /srv/private/checkpoints once private/checkpoints/ exists; the command refuses a directory it cannot read.
How it reads.
- What it checks: each workspace's chain on the live database, from genesis to the head the database keeps. It recomputes every hash and checks every sequence and link.
- How: a page of 500 events at a time, inside one read-only snapshot, as the runtime role with no credential. A gateway that appends while it reads is not held up, and an event committed mid-read is not seen.
- Which workspaces: every workspace in the directory, an inactive one included.
--tenant, which may be given again, names workspaces instead of--all-tenants. - Writes: none.
Retained checkpoints.
- What they are: the
*.head.jsonfilesaudit-export --checkpointwrites, given with--checkpointor found in--checkpoints. - How they are placed: each is placed on its workspace's chain, and a workspace may have several, from different times.
- A workspace that no longer exists: a run over every workspace that reached them all also checks, by name, each workspace a retained head names that the directory no longer holds. A history removed with its workspace therefore fails
BEHIND_CHECKPOINT, and an operator who purges a workspace removes its checkpoints too. The report counts the heads a run did not place (named workspaces only, or a run its budget cut short) ascheckpoints_unplaced. - Where to keep them on Community: copy them into
private/checkpoints/. The jobs service passes that directory whenever it exists, and no container can write to it. The host account that owns it can, and it also holds the database password, so this placement catches a change made through the database alone, or by accident. Keep the originals off the host as well (Recovery). - Refusals: each one stops the run before it reads the database:
INVALID_CHECKPOINT: a checkpoint file it cannot read, a link to nothing among them;CONFLICTING_CHECKPOINTS: two files with different hashes at one sequence;CHECKPOINTS_UNREADABLE: a directory it cannot read;TOO_MANY_CHECKPOINTS: more than 1,024 files.
What a failure looks like. A workspace that does not verify is logged as audit.chain_failed at ERROR, with its tenant_id, its code and a sequence. The codes are the restore verifier's (Recovery), and one more:
CHAIN_BROKEN: an event altered, reordered, deleted or added, or a chain that does not end at the stored head. The sequence is where the chain first breaks: the first row past the head, or the head itself when its hash was substituted.DIVERGES_FROM_CHECKPOINT: a retained head whose hash the chain does not carry at its sequence, meaning history was replaced. The sequence is that retained sequence; history may differ from an earlier point.BEHIND_CHECKPOINT: events a retained head proves existed are missing. The sequence is the first missing event.UNKNOWN_TENANT: a named workspace the directory does not hold.
The run then exits 1, so the jobs service logs job.failed for it. It also exits 1 when a workspace's chain could not be read; that workspace is logged as job.skipped. A misuse, a schema the code does not expect, the directory walk's own refusal or a database error exits 2.
Where the findings go.
- Under the jobs service: each
audit.chain_failedline goes straight to the service's log as the command writes it, all of them, even when the run is stopped at the service's ten-minute limit. The report it prints is discarded. - By hand: the report is printed, and names each failed workspace with its problem.
- What is not recorded: nothing is appended to any chain, because a chain cannot record its own break. The log line and the exit code are the record.
- Alerting: routing
audit.chain_failedto a person is the operator's choice of log alerting; no responder is named here.
What it cannot see.
- A whole history replaced together with its stored head. Only the database owner can do that, and it leaves a chain that verifies. A retained checkpoint taken before the rewrite shows it (residual risk R2 in the threat model); with none, nothing does.
- Workspaces the run's budget did not reach. These are counted in
not_reachedand do not fail the run, and the next run starts somewhere else in the directory. - Very long chains. One workspace's chain is read in one transaction. Verification is roughly a microsecond-scale cost per event, so a chain of some millions of events takes tens of seconds and holds back vacuum while it is read; one far longer would pass the jobs service's ten-minute limit and end as
job.timed_out.
Notify approvers of pending approvals
When admission creates an approval, one approval_notifications row is queued in the same transaction for every configured destination whose classes include approvals (the default) and whose approvers filter names an assigned approver of that target and action (or that has no filter) and whose messages list includes pending, the default. Nothing is sent from the request. uv run execbound notify --all-tenants [--limit 50] delivers the queue in every workspace that an email, Slack, Teams or webhook destination in EXECBOUND_RUNTIME_CONFIG whose classes include approvals names, with that configuration's destinations and mail block and no credential: a row is queued only for a configured destination, so no other workspace can have anything to deliver (notifications.destination_tenants). It is what the jobs service runs. uv run execbound notify --tenant <id> --credential-file <admin file> [--limit 50] drains one workspace as a current ADMIN. A webhook destination receives a signed JSON body (X-ExecBound-Delivery and X-ExecBound-Signature, the same headers as monitoring alert delivery) that carries only identifiers, catalog labels, timestamps and the fixed approval page link; an email destination receives one plain-text message through the deployment's relay (below); a slack or teams destination receives a message in a channel (below). Every kind is identifier-only and links to the approval's own page, with a footer pointing at the basic page, /approvals/{approval_id}/classic, the server-rendered approval page that stays for a phone whose console does not load; none carries an approve or refuse control, because a mail recipient or a chat identity is not an ExecBound principal. A failed attempt is retried with exponential backoff, the attempts 5, 25, 125 and 625 seconds apart, and marked FAILED after five attempts, or at once when the next attempt would fall later than fifteen seconds before the approval's deadline, and the row carries the reason as the failed attempt's code: RECEIVER_UNAVAILABLE for a webhook that does not answer 200, 201, 202 or 204, a chat webhook that answers 5xx, or a relay or endpoint that cannot be reached; RECEIVER_REFUSED or RECEIVER_RATE_LIMITED for a chat webhook that refused the message or asked for a pause; DESTINATION_NOT_PUBLIC for a Slack or Teams destination whose name resolved to an address that is not public, which the drain never connects to (a webhook records that as RECEIVER_UNAVAILABLE; Egress); and MAIL_REFUSED, MAIL_AUTH_FAILED, TLS_UNVERIFIED or TLS_UNAVAILABLE for a relay that refused the message or the connection. A notification whose approval is no longer pending is marked FAILED with APPROVAL_CLOSED and never sent, and one whose approval is still pending but lapses within fifteen seconds is marked FAILED with LAPSED_BEFORE_DELIVERY and never sent, because it could not arrive with time to decide (notifications.LAPSE_MARGIN); neither spends an attempt or changes the approval, and both count as closed in the drain's output. Each row has a kind: approval.pending for the announcement; approval.reminder for the one reminder a destination that lists reminder gets for an approval still pending once half its window has passed with at least three minutes left (notifications.REMINDER_FLOOR), routed as the announcement is and sent under its closed, lapse and retry rules, never changing the approval (Notifications); and approval.closed for the one outcome message a destination that lists closed gets once the approval it was told of is decided, withdrawn or 30 seconds past its deadline, sent like any row, retries included, but never closed by its approval's state (Notifications). The drain queues both follow-ups itself at the start of each run, in its own transaction, at most 50 approvals' reminders a run, soonest deadline first, and at most 50 outcome messages a run, so the next run reaches the rest. A row whose message its destination no longer lists, because the service restarted with a changed messages list, is marked FAILED with MESSAGE_OFF and never sent. A reminder or outcome email is sent only below the daily cap less a tenth of it, and is otherwise marked FAILED with RATE_LIMITED at once, so the last of the cap stays for announcements. A queued row no drain will reach again, because its destination was removed or because its approval closed after its workspace's last destination went, is settled by the scheduled recovery pass without sending: APPROVAL_CLOSED for a closed approval (never an outcome row, which exists because its approval closed), and DESTINATION_UNKNOWN for a destination the recover job's own configuration does not name, or no longer lists approvals, once the row is a day old (notifications.ORPHAN_AFTER); a row a drain holds under a live lease is left to that drain (notifications.settle_orphaned). --tenants-file <map> drains every tenant in a JSON object of tenant id to jobs token, as recover does, reporting each tenant's counts or {"skipped": code} for one that refuses and exiting 0 either way, a database at another schema revision included, where every tenant is skipped. A database whose grants do not match what this process's plugins claim stops a run in every form with exit status 2 and a sentence saying which is wrong: grants for a plugin EXECBOUND_PLUGINS does not name, or grants that plugin's own upgrade command has to restore. It is kept for an operator's own schedule. --all-tenants checks the schema before it visits any workspace, so a database at another schema revision stops it with exit status 2 and the runtime check's sentence, "Database refused: the runtime connection failed its safety check." The three forms are exclusive. Schedule --all-tenants like the recovery run. uv run execbound notifications --tenant <id> --credential-file <admin file> [--state FAILED] lists rows with their kind, state, attempts and error codes, and the approvals page shows each pending record's delivery state. A notification never grants, extends or replaces an approval, and delivery failure never changes approval or execution state.
Send notifications by email
An email destination names one bare address in to (no display name) and may carry the same approvers filter; one destination per approver is the usual shape, and a chat channel is the shape for a team. Email goes through the deployment's one relay, the mail block beside notifications in the private configuration:
"mail": {"host": "smtp.resend.com", "port": 465, "tls": "tls", "username": "resend", "password": "<API key>", "from": "no-reply@notify.example.com", "daily_limit": 500}
tls is tls (implicit TLS, port 465) or starttls (port 587). There is no plaintext mode: a relay that does not offer STARTTLS is never sent to (TLS_UNAVAILABLE), and the relay's certificate and name are verified against the system trust store (TLS_UNVERIFIED). A configuration with an email destination and no mail block refuses to start, and the password is never logged or rendered. The message is text/plain with a fixed subject, [ExecBound · <workspace> · EB-n] Decide by <time> UTC: <action> on <resource class>, the headers Auto-Submitted: auto-generated and X-ExecBound-Delivery, and a Message-ID derived from the delivery id, so a message delivered twice after an uncertain attempt collapses into one in the mailbox. Labels in the subject and body are reduced to letters, digits, spaces and ._:/()-, so no header or link syntax can reach them. Outbound email per tenant is capped at daily_limit messages in any 24 hours (default 500): a message over the cap stays PENDING with RATE_LIMITED and is tried again an hour later without spending an attempt.
- Resend. Host
smtp.resend.com, usernameresend, the API key as the password; port 465 withtlsor 587 withstarttls. The free tier covers 3,000 messages a month. - Google Workspace. Host
smtp.gmail.com, port 465 withtlsor 587 withstarttls, the mailbox's address as bothusernameandfrom, and an app password for that mailbox as the password (Google requires 2-Step Verification for app passwords, and stopped accepting a plain account password from apps on May 1, 2025). Google allows 2,000 messages a day from this server. Its IP-based relay,smtp-relay.gmail.com, needs the host's outbound addresses allowlisted, which is unsafe where a hosting platform shares those addresses with other customers. - Any other relay. Its host and credentials, with 465 and
tlsor 587 andstarttls. Port 25 is refused. - Hosting platforms. Some close outbound 465 and 587 on free tiers and port 25 everywhere; check that 465 or 587 is open from wherever
notifyruns.
The sender domain is a DNS task. Send from a dedicated subdomain such as notify.example.com, so the root domain's reputation is not shared with automated mail. The relay gives the SPF and DKIM records for the subdomain when it is added; publish them, add a DMARC record for the subdomain, and move the root domain to p=reject once every other sender on it is aligned. Bounces and complaints are not read back: a message the relay accepted counts as delivered, and a refusal is what the outbox shows. The password can arrive on its own. Where the configuration arrives as EXECBOUND_RUNTIME_CONFIG_JSON, as on a platform that supplies configuration through the environment, leave password out of the mail block and set EXECBOUND_MAIL_PASSWORD on every service that carries the configuration; boot.py merges it into the block at start and scrubs it from the environment. The variable is ignored while there is no mail block, so it can be set first; a block with no password from either place, or with one in both, refuses to start with a message that names the variable.
The weekly summary. Two more keys of the same mail block offer people a plain-text summary of their own counts each Monday (Notifications): weekly_summary (default false) turns it on, and weekly_summary_daily_cap (default 200, 1 to 100,000) caps how many summaries the whole deployment sends in a day, beside the per-workspace daily_limit. With the summary off nobody is offered the switch under My account and execbound weekly-summary-run refuses to start. With it on, the jobs service runs weekly-summary-run --limit 50 every hour: it queues each subscriber's summary for the week that ended on Monday 00:00 UTC inside that subscriber's own workspace, sends what is due, retries a relay's refusal with the invitation outbox's backoff, skips a week with nothing in it and gives up on a summary not sent by the Thursday. Like invitations-run it needs no identity and no per-tenant configuration. A one-click stop the database refuses (a missing grant, an outage, or a clock out of step with the database's) ends nothing and answers the person as every other stop does; it logs one weekly_summary.refused line at ERROR with the code UNAVAILABLE and no ids, since nothing of the link may reach a log.
Send notifications to Slack or Teams
A slack or teams destination posts the approval to a channel: the same labels, deadline, workspace, space and requesting agent's handle as the email, with the deadline first, a button that opens the approval's own page, and a second button with the footer sentence that opens the basic page. The URL is the secret. It is never logged, rendered or exported, and the tenant settings page shows only its host. Neither kind carries an approve or refuse control, because a chat identity is not an ExecBound principal and a control in the channel would route authority around the approval page.
- Slack. Create a Slack app for the workspace, turn on incoming webhooks and add one to the channel; its URL is an
httpsaddress onhooks.slack.comwhose path starts with/services/. Another host, another path or any query is refused at start. Legacy custom-integration webhooks are deprecated by Slack and are not the target. The message is Block Kit with every text objectplain_text, so no mention, link or formatting syntax is interpreted. - Teams. Office 365 connectors were switched off in May 2026, and
*.webhook.office.comURLs are refused. In the channel, create a workflow from the template "When a Teams webhook request is received" followed by "Post card in a chat or channel". Its URL is on a*.logic.azure.com,api.powerautomate.com,api.powerplatform.comorflow.microsoft.comhost and carries its signature in thesig=query, which is why this kind alone keeps a query. The workflow posts one Adaptive Card as the Flow bot.
Within one drain each chat destination receives at most one message per second, the rate Slack documents for incoming webhooks. A 429 answer reschedules the message at its Retry-After, in seconds or as an HTTP-date measured from the drain's clock (held to 1 to 3,600 seconds, 30 when absent or unreadable), and counts as an attempt (RECEIVER_RATE_LIMITED); another 4xx is RECEIVER_REFUSED and a 5xx or a network failure RECEIVER_UNAVAILABLE, each retried under the five-attempt rule. Slack delivers on 200 and a Workflows trigger on 200 or 202, since it accepts the request and runs the flow afterwards; a flow that fails after accepting is not visible here.
Send monitoring findings to destinations
Each monitoring finding can be sent once to every email, Slack, Teams or signed webhook destination in the private configuration that lists findings among its classes, as decided on 17 October 2026, except one the synthetic directory raised, which is never sent (Scout). Tines monitoring says what a finding message carries and shows a destination.
Configuration. A destination's classes is a non-empty list of approvals and findings, each named once. Without it a destination is ["approvals"], what every destination was before, so an existing file sends no finding. A findings destination may add rules, a list of rule versions such as sensitive-action.v2 or monitored-path-change.v1, and spaces, a list of Space ids, each non-empty and without repeats, and then receives only findings of those rules and in those Spaces; without either it receives every one. rules and spaces are refused without findings, messages and approvers are refused without approvals, and a security-events destination takes no classes. An unknown class or rule, a repeated entry or a list whose class is missing refuses the file at start without quoting the value, and a reload that changes classes, rules or spaces is refused, so restart the gateway to change them, as for approvals. A destination without approvals is never queued an approval, never counts as announcing one and never schedules notify; it is listed on Alert settings alone, not on Workspace settings. Each monitor-process pass reads the file again, so what a restarted gateway lists on Alert settings is what the next pass sends to.
Every administrator of the workspace sees every findings destination. Alert settings names each by its id, kind and host, its rules, and the Spaces the reader can read, counting any others without naming them, and never shows its URL, address or secret. An administrator seated in some Spaces therefore still sees the id and host of a destination scoped only to Spaces they cannot read. Never put a customer's name in a destination's id or host, because a Space-narrowed administrator can see both.
Running it. process --deliver sends findings in both modes: process --config <monitor file> --deliver as that file's administrator in its own workspace, and process --all-tenants --deliver in every active workspace as that workspace's scheduled actor, under its own fence. It reads only the destinations, the mail block and the origin of the file EXECBOUND_RUNTIME_CONFIG names, and builds no connector and loads no plugin from the file. Every finding's link is built from the origin, so it is held to the console's own rule and to a finding message's, which also needs a lower-case scheme and a host of ASCII letters, digits, dots and hyphens or a bracketed address: a file whose origin only the console takes, such as https://my_host.example, does not read for --deliver. Without the variable it exits 2 with --deliver needs EXECBOUND_RUNTIME_CONFIG., and it is refused beside --deliver-local. Where the file does not read, the pass still evaluates, then logs monitor.delivery_refused and exits 2, with nothing routed or sent. A scheduled monitor-process runs with --deliver, and the jobs service passes it wherever it has a runtime configuration that reads (the jobs service). --deliver-local is refused where the runtime configuration names a findings destination (FINDINGS_DESTINATIONS_CONFIGURED): a finding is routed once, so the loopback test receiver would take every pending finding from the configured destinations. How the hosted service sends findings is documented with the hosted service.
Routing. A pass first closes up to 5,000 of the workspace's queued findings that are more than a day old, oldest first, and then routes up to --limit (50) more: one delivery for each destination configured at that moment whose classes, rules and Spaces admit the finding. A finding is routed once, even where no destination admits it, so a destination added later receives nothing raised before it, and turning findings on never sends a backlog. A finding first routed more than a day after it was queued is closed TOO_OLD_TO_SEND, one already acknowledged FINDING_ACKNOWLEDGED, and one the synthetic directory raised SYNTHETIC_DIRECTORY, with no delivery; the database refuses a delivery of one, whoever writes it. A delivery whose destination is removed, or no longer lists findings, before it is sent is closed DESTINATION_UNKNOWN or CLASS_OFF, and one whose finding is acknowledged first FINDING_ACKNOWLEDGED; none of them is sent, and no closing spends an attempt. Narrowing a destination's rules or spaces recalls nothing already routed.
Upgrading. Findings queued before the upgrade are routed by the first pass with --deliver, like any other, and every existing destination reads as approvals alone, so nothing is sent at the upgrade. A findings destination added before that first pass receives the queued findings up to a day old, at most 50 routed in each workspace per pass; older ones are closed TOO_OLD_TO_SEND.
Sending. A pass then sends up to --limit due deliveries in the workspace, one at a time, each leased for 60 seconds and rendered before its attempt is counted. A delivery makes at most five attempts, 5, 25, 125 and 625 seconds apart. A 429 waits its Retry-After, in seconds or as an HTTP-date measured from the drain's clock (held to 1 to 3,600 seconds, 30 when absent or unreadable), and counts as an attempt (RECEIVER_RATE_LIMITED), another 4xx is RECEIVER_REFUSED, and any other answer, a redirect, a timeout or an unreachable receiver is RECEIVER_UNAVAILABLE; email takes the relay's codes, as approvals do. A webhook counts 200, 201, 202 and 204 as delivered. Every HTTP send ends within ten seconds, from connecting to its last byte, and each SMTP step within ten. A fifth lease that expired before its attempt settled ends DELIVERY_UNCERTAIN with no sixth send. The delivery id never changes, so a receiver deduplicates on it, and an email's Message-ID is built from it. Within a pass a Slack or Teams destination receives at most one message a second. Under --all-tenants each workspace's line in the pass's output counts routed, delivered, failed and closed, and under --config the output's delivered counts what was sent; each settled attempt, and each delivery closed without one, writes monitor.delivered (Structured logs).
A send may have landed. RECEIVER_UNAVAILABLE and DELIVERY_UNCERTAIN do not prove the receiver has nothing, because an answer can be lost. Nor does a delivery closed without a send once it has an attempt: attempts above 0 on a DESTINATION_UNKNOWN, CLASS_OFF, FINDING_ACKNOWLEDGED or RATE_LIMITED row means an earlier attempt's lease expired before it settled, and that send may have arrived. Look for the delivery id at the receiver before treating such a delivery as unsent.
The email cap. Findings email counts toward mail.daily_limit with the workspace's approval email, read from both outboxes over every email destination the configuration names for the workspace, and is sent only below the cap less a tenth of it (at least one message), so the last tenth stays for approval announcements. Over that, a findings email waits an hour without spending an attempt, and fails RATE_LIMITED once its delivery is a day old. The cap counts one workspace's approval and findings email, not other mail sent through the same relay, such as the weekly summary.
Egress. A webhook, Slack or Teams destination, for findings and approvals alike, is reached only when every address its name resolves to is public: not private, loopback, link-local, shared, multicast, reserved or unspecified, nor an IPv6 address that carries such an IPv4 address, nor one outside global unicast 2000::/3 that carries none. The first three addresses are tried in the resolver's order, each connected as checked, with TLS still verified against the URL's own name; no redirect is followed and no proxy is read from the environment. A findings destination refused this way fails the attempt DESTINATION_NOT_PUBLIC, as an approval Slack or Teams destination does, while an approval webhook records RECEIVER_UNAVAILABLE, and each is retried like any failure, since a DNS answer can change. A URL whose host is a loopback literal, such as the local test receiver's, is the one exception, so a receiver on a private network needs a public HTTPS endpoint or a relay on a loopback address. The security event export builds its own client, outside the guard, and still counts only 200 and 204 as delivered. The mail relay is the deployment's own and is not checked.
Seeing it. Monitoring → Alert settings lists the workspace's findings destinations and the newest 200 deliveries with their destination, state, attempts and last error, the findings not routed yet or closed before routing among them. uv run python -m execbound.monitoring.commands deliveries --credential-file <admin file> [--state FAILED] lists the newest 200 rows, newest first, as a current administrator, routed findings included, by identifiers, states and codes alone.
Turn the console's assistant on for a tenant
The assistant exists where the gateway's private configuration carries an assistant block and EXECBOUND_ASSISTANT_API_KEY is set; without the block no assistant address is served at all. Each tenant is off until a workspace administrator turns it on, which is an audited change with a reason; an administrator seated in spaces reads the switch and is refused the change (INVALID_IDENTITY):
uv run execbound tenant-assistant --tenant <tenant> --credential-file local-data/admin.token --set on --reason "Pilot"
The answer states what the provider retains: prompts and answers are kept for up to 30 days under its commercial terms, zero retention is by separate agreement, and the provider's newest high-end models keep 30 days even under that agreement. Tell whoever owns the tenant's data before turning it on for them. The same switch is on Workspace settings for a workspace administrator. The caps are the operator's: person_hourly_turns and tenant_daily_tokens in the block, counted when a turn is created.
Questions, steps and answers are nulled thirty days after a turn completes, by the scheduled recover --all-tenants pass: in every active workspace it calls assistant_turn_retire_due, which holds no actor and measures the thirty days on the database's own clock. An administrator's recover --tenant or --tenants-file run retires them too. The row and its token counts stay, and a workspace's entry in the report carries assistant_turns_retired. A turn's text is never in the audit chain, which cannot be erased.
Recover an uncertain execution
- Retain the execution ID, original key, gateway database, provider store/journal and the keys needed to verify that provider's evidence. Use scoped status to inspect
DISPATCH_COMMITTEDorINDETERMINATE; neither proves failure. - Restore connectivity to the same provider state/configuration. Do not recreate its SQLite store, reset impact, edit a terminal result, or send the mutation again.
- Log in as a current ADMIN and open Activity. Submit reconciliation for that execution with a reason. The coordinator verifies exact operation/command/account/plan evidence and rechecks administrator/session authority around network work.
- If no journal exists, retain uncertainty. The optional cancellation action uses the control credential to request an atomic
CANCELLED_NOT_EXECUTEDtombstone; it succeeds as no-effect evidence only if the provider can fence any later arrival of the original operation. - Verify the terminal view and new audit events. Executed evidence confirms retained impact. Valid terminal non-execution releases its reservation and guard. Invalid, foreign or missing proof leaves uncertainty held.
There is no automatic dispatch retry worker or owner lease takeover: a lost send stays lost until evidence or a tombstone settles it. A scheduled recovery run settles what evidence allows. execbound recover has three modes, and takes exactly one:
uv run execbound recover --all-tenants [--limit 50] [--grace-seconds 300] [--budget-seconds 480]is the scheduled pass, and what the jobs service runs. It holds no credential. It walks the directory of tenants (job_tenants, which every new tenant joins) from a random start, wrapping round, and in each active workspace acts as that workspace's scheduler principal: a principal of kindSYSTEM, labelled "ExecBound scheduler", made the first time the pass finds something to act on there, which the database keeps from ever authenticating or holding authority (threat model T3.12 and T4.47). A deactivated workspace is counted and left alone. The pass starts no workspace once--budget-seconds(1 to 3600, default 480) have passed and counts the restnot_reached; the next run starts elsewhere. It never takes--cancel-unattempted, and the CLI refuses that flag,--tenant,--credential-fileand--tenants-filebeside it, and a--limitor--grace-secondsout of range, before anything runs.uv run execbound recover --tenant <id> --credential-file <admin file> [--limit 50] [--grace-seconds 300] [--cancel-unattempted]runs one workspace as a current ADMIN, attributed to them. Cancellation stays behind an administrator's mode (--tenant, or--tenants-file, each as a current ADMIN).uv run execbound recover --tenants-file <map> [--limit 50] [--grace-seconds 300] [--cancel-unattempted]runs each tenant in a JSON object of tenant id to jobs token as that tenant's ADMIN. It is kept for an operator's own schedule.
Every mode reads EXECBOUND_RUNTIME_CONFIG and reaches an account through the runtime, so a request on a sample account is settled through the sample connection the gateway derives, as Reconcile does, when the configuration carries a sample_providers block; an account with neither a configured nor a derived connection is counted in skipped_no_connector (recovery_run.py, runtime.py). A run examines up to the limit of DISPATCH_COMMITTED and INDETERMINATE executions whose last change is older than the grace period (oldest first), and puts each through the same reconciliation path as the Activity form: fence, the actor's check (the ADMIN check for an administrator, the scheduled check for the pass), audited request, authenticated journal lookup, trusted verifier, settlement. Verified evidence settles the operation; an attempted operation without a journal record becomes or stays INDETERMINATE and is never cancelled automatically; an operation the provider never received a send attempt for is left untouched unless --cancel-unattempted is set, in which case the provider's atomic tombstone is requested and the operation settles only on that proof. The actor is rechecked before every operation, and a run whose administrator is revoked, or whose workspace is deactivated, stops at the next check with what it had already done: --tenant then exits 2 with INVALID_IDENTITY. The run never resends, prints its counts and a run identifier, which three audited reasons carry (the reconciliation request, the cancellation request and a settlement's transition), and uses a single-connection pool so it cannot starve admission. It exits 2 when the credential, the configuration or the database's own safety check is refused, and when the database's grants do not match what this process's plugins claim it says which of the two is wrong, as notify does; in the two modes that visit many workspaces, one workspace's refusal is that workspace's outcome and the exit code stays 0, which under --tenants-file includes a database at another schema revision, where every tenant is skipped. Read rotation-readiness before and after to see what remains.
What the scheduled pass adds. Before it visits any workspace it checks the schema (check_runtime) and the directory's policies (jobs.check_directory_policy), and exits 2 on a mismatch, with the runtime check's refusal sentence or UNSAFE_JOB_POLICY. After a workspace's recovery it runs two sweeps, each whether or not the other failed: it retires assistant text older than thirty days, and it settles, sending nothing, queued approval notifications whose approval is closed or whose destination is gone, which no drain may reach again (Notify approvers). Give the recover job's private configuration the same notifications block as the notify job's: the pass settles a row whose destination its own copy does not name once the row is a day old (notifications.ORPHAN_AFTER), so a copy that lags would fail notifications that could still be delivered. Its report counts workspaces visited, inactive, failed and not_reached, and lists only the workspaces where something happened or that failed; a failed workspace's entry carries its code as skipped beside what its run had already committed. A workspace's scheduled work stops when the workspace is deactivated, and every workspace's when the job is suspended; there is no per-workspace pause.
The same run handles externally executed operations, on a branch taken from the row's report window before any connector is looked up. An unreported grant past its window is swept to INDETERMINATE with the reason UNREPORTED_GRANT and nothing is settled, refunded or released: the impact stays charged and the canonical claim stays held. The cancellation mode never applies — reconcile_operation refuses a checkpoint in every mode, because a non-execution on absent executor evidence is the refund the contract forbids. A second selection releases the canonical claim a settlement on the executor's bare word left held, once the act window has passed, appending execution.claim_released with the reason ACT_WINDOW_CLOSED and touching the execution itself not at all. Its predicate is a settled row at tier ASSERTED whose claim is unreleased, so it reaches a row settled FAILED_EFFECT_UNKNOWN by a control point's word exactly as it reaches one settled EXECUTED, with no new mechanism.
A third selection ends an approval nobody answered: an execution still PENDING_APPROVAL whose approval is PENDING or APPROVED and whose expires_at passed more than the grace period ago. Until this existed, an approval reached EXPIRED only when something touched it, so one the agent never retried and no human ever opened stayed PENDING indefinitely and kept inflating the count of decisions held for a person. It is ended through the same helper a late touch uses, so the chain holds the same pair of transitions, and it releases nothing because a pending approval holds nothing (recovery).
Alert on unresolved and on nothing else in this list.
| Counter | Meaning | Alert? |
|---|---|---|
unresolved | A row was due and would not move | Yes |
open_grants | A grant inside its report window: healthy, simply not due | No |
uncertain_grants | A grant already in retained uncertainty; this run has no exit to take, and the executor's late report and the administrator's settlement are the two that exist | No |
swept | Unreported grants this run moved into retained uncertainty | Watch the trend |
settled_grants | A grant an administrator or its own executor settled between this run's selection and its sweep | No |
released | Claims released now that the act window has passed | No |
expired | Approvals nobody answered before their deadline, ended by this run: the approval to EXPIRED and the execution to STALE_AUTHORIZATION, reason APPROVAL_EXPIRED. Nothing is released, because a pending approval reserves no impact and claims no target | Watch the trend |
Both grant counters exist because both used to land in unresolved, which alerted an operator five minutes after every grant was issued and, for a swept grant, forever.
Operations console
The Activity page is the operations console for a current ADMIN: filter by state (UNCERTAIN lists committed and indeterminate operations), action, agent principal and time window, page through 50 records at a time, read each uncertain operation's dispatch evidence (operation identifier, attempt time, last change) and reconcile it with a reason from the row's form. The tenant home links straight to the uncertain queue. The same settlement is available without a browser:
uv run execbound reconcile --tenant <tenant> --credential-file local-data/admin.token --execution <execution> --reason "Provider confirmed the effect"
reconcile needs EXECBOUND_RUNTIME_CONFIG for the connector, rechecks the current ADMIN, settles from the provider's journal through the same path as the console form (--cancel-if-missing fences an operation the provider never received), and prints the resulting view; recover remains the bounded batch run for many operations.
Change the mode, the deadline or the stop from the console
A workspace administrator can do from /tenant what tenant-mode, tenant-approval-deadline, tenant-stop and tenant-resume do from the command line, and nothing more: each form calls the same store function with the session's identity and a required reason, so the audit chain shows tenant.mode_changed, tenant.approval_deadline_changed, tenant.stop_activated and tenant.stop_released with the same payloads whichever path was used, and the same refusals apply (a no-op change is INVALID_STATE, a deadline outside 120 to 3,600 seconds or off is INVALID_DEADLINE, an approver is refused). The page also shows when each setting last changed and by whom, from those events. An administrator seated in spaces reads the whole page, the stop and the mode included, and is offered none of these controls nor the assistant switch, because each applies to every space; the console answers them 403 and the store functions refuse them INVALID_IDENTITY (decided 8 October 2026). The organization's MFA requirement on Account security is the same: only a workspace administrator is offered it, its address answers anybody else 403, and account_security_change and user_security_change refuse it since 0074_workspace_settings_admin. Data and closure requests on the same page is the queue of every person's requests, from every space, with each requester's name, so only a workspace administrator reads it or records a response there: an administrator seated in spaces is shown a note in its place, GET /api/console/v1/account/requests and POST /api/console/v1/account/requests/resolve answer them 403 before any request is read or written, and since 0075_workspace_requests_admin account_change refuses their response too. Their own requests, filed and read on My account, are unchanged. A person's own sessions are on /settings, where Sign out other devices ends every other session signed in as them; an ADMIN ending someone else's sessions is the People page's, not this one's.
Settle an unreported checkpoint grant
A grant for an externally executed operation has no connector, so the console's reconciliation control offers a different form for it: paste the executor's own signed record with a reason, and the row settles through the connector-free path with the verifier that checks the tenant's registered executor keys. The row decides which form is rendered; a grant with no record, a record against an operation ExecBound dispatched, and a cancellation for a grant are each refused, and a record that will not verify answers 400 because the administrator supplied it rather than a failing dependency.
execbound reconcile is not this exit. It looks the account up in the live connector inventory and answers CONNECTOR_UNAVAILABLE, or INVALID_STATE if a connector was configured for that account afterwards. Use the Activity page.
The control is offered for a grant nothing has settled, committed or indeterminate. Two things to know before submitting one for a grant whose act window is still open:
- Settling a live grant closes ExecBound's route on its executor. Its later report is refused unless it restates the settled outcome.
- It does not stop the executor acting. The act grant is still live, nothing revokes it, and ExecBound cannot undo what the executor does at its own vendor. Only the customer's platform can stop that.
A settled row that is not the correctable one below is offered no control, and an attempt against one is refused INVALID_STATE; the refusal is audited under an event that names no execution, so it leaves a trace that an attempt happened rather than a way to find the row from it. The canonical claim a settlement on an executor's bare word left held is released by the scheduled run once the act window has passed.
Correct a charge a control point's own report created
One settled row is offered the same control, and it does something different with it. A row a control point closed at FAILED_EFFECT_UNKNOWN — tier ASSERTED, retained reason REPORTED_FAILURE — is a row the platform said failed and ExecBound could check nothing about, charged exactly as a reported success is. Submitting an executor-signed non-execution into its record field corrects that: execution.reconcile rewrites the impact to a non-execution with no confirmation time, releases the canonical claim, and writes the verified evidence and the ATTESTED tier over the asserted ones. The page says so beside the control, and the record afterwards says what it corrected, read from its own transition history.
It corrects that one charge and reopens nothing. A signed record stating the executor acted is refused INVALID_STATE against the same row — that would release the claim before the act window, which is the promotion the checkpoint guide refuses — a corrected record is settled, and no report of any kind produces a correction. execbound reconcile is not this exit either: it looks the account up in the live connector inventory and refuses a grant in every mode. Two things bound what an operator can do here rather than what the page shows: the correction needs an executor key usable at the grant instant, so a tenant whose ring was revoked as COMPROMISED cannot correct a grant issued before the revocation, and a grant that settled any other way is not correctable at all.
Collect from a connected Tines tenant
Read-only discovery and collection runs as a command, never as a background job inside the gateway: an operator runs collect-tines, and collect-due collects each due connection on its schedule where its host can open it: an operator key-file connection where it holds the key file, a sealed Tines connection where it holds the collector's key, and a consented Entra directory where it holds the platform app's credential (the jobs service; Tines monitoring). Register one connection per tenant with python -m execbound.monitoring.commands create-connection, giving a private env file that holds TINES_BASE_URL and a read-only TINES_API_KEY; only the key's SHA-256 fingerprint is retained, and a key file whose fingerprint does not match is refused before any request. Run collect-tines on the schedule the tenant's audit volume needs, with --inventory when a full metadata sync is wanted (it also runs automatically once a day) and --audit-pages bounding the work of one run. Revoke with revoke-connection; the row and its collected evidence stay.
Read the run's printed counts, not its silence: BACKLOG means the page budget was exhausted and the next run continues, RATE_LIMITED carries the platform's Retry-After, AUTH_LOST means the key lost permission for the audit API, and RETENTION_GAP means the cursor fell behind the platform's retention and a window is unrecoverable. Each has a collection-health.v1 finding per episode. Coverage matches a discovered story to a reporting source by the platform story identifier, so register sources with the story's Tines id; the Connections page reports how many registered sources match no discovered story.
The first collected record pins the platform tenant the connection speaks for, and every later record must agree with it; this is trust on first collection, so run the first collection against the tenant you intend before scheduling it. A live probe has confirmed the inventory half against a real tenant; the audit half (its time filter, operation names, rate limits and retention) stays fixture-verified, and the pages say so. See the Tines monitoring guide for the full contract.
Collect agents from a connected directory
Scout's first source is Microsoft Entra Agent ID, read-only through Graph. Like a Tines connection it is collected by a command and never by a background job inside the gateway: an operator runs collect-entra, and collect-due collects it on its schedule where its host holds the connection's key file or, for a directory that consented to the platform app, the platform app's credential.
The collection app registration is not the registration that executes anything. One client secret must never be both the collector and the executor; the two share the directory and nothing else. Consent the collection registration to AgentIdentity.Read.All, Application.Read.All, AuditLog.Read.All and Directory.Read.All, none of which can write (without AuditLog.Read.All the directory is still collected, but its audit log is not read and the connection is not reported complete), and put ENTRA_DIRECTORY_ID, ENTRA_CLIENT_ID and ENTRA_CLIENT_SECRET in a private env file. Register with create-connection --kind entra_agent_id; only the secret's SHA-256 fingerprint is retained, and a file whose fingerprint differs is refused before any request. A tenant runs one connection per platform, so a Tines connection and this one coexist.
Run collect-entra. Read its printed counts, not its silence. directory: verified means the platform confirmed it speaks for the directory the connection names; unverified means the organization read was refused and the claim is unestablished rather than assumed; DIRECTORY_MISMATCH means the registration speaks for a different directory and nothing it returned was stored. AUTH_LOST on the agent identity capability ends the run rather than reporting zero agents, because permission to read agents is the run's own authority.
Two follow-up acts are yours, and neither is inferred. link-agent says which ExecBound principal an external agent is, which is what allows a settled execution to be attributed to it; a display name that resembles a principal is never evidence. The statement is a binding to an active agent principal, one to one, and the Agents page's Bind and Govern this agent make it too (From discovery to governance). accept-reach records that the organization has decided to live with one ungoverned path, with a reason code and an optional expiry; it leaves the reach row exactly where it was, because an acceptance is not the path closing.
A live tenant was read on 18 September 2026; what it did not exercise stays fixture-verified, and the pages say so. The claim boundary, the certainty ladder, what the live read corrected and the coverage holes the pages state are in SCOUT.md.
Expiry, rotation and retention
Synthetic validation credentials last eight hours; the older seed-demo issues one-hour agent tokens. Trusted validation context expires 300 seconds after seeding: seeding sets each resource's and incident's observed_at to the seeding time, and the demo bundle sets no context_settings, so the contract default max age of 300 seconds applies. A bundle may set a max age up to the contract's 3,600-second ceiling, but a new max age applies only to newly resolved requests and never extends a frozen plan; see Context contracts. Approval expires at the earlier of the tenant's approval deadline after its creation (120 seconds unless tenant-approval-deadline sets 120 to 3,600 seconds or turns it off) and the frozen context deadline. Both the human decision and the agent's subsequent retry must finish before that deadline; approval does not extend it. Human sessions end after 15 minutes idle and 8 hours at most, and expire with their backing credential; logout revokes their stored session. Pending status tokens are shorter-lived and bind the exact credential. For another demo, generate fresh fixtures instead of altering the clock or extending an approved snapshot.
Continuation and provider evidence keys use key IDs and keyrings, and the private configuration file can rotate them without a restart; see Rotate signing and evidence keys. Losing provider evidence keys can prevent settlement of retained uncertainty, so a key leaves a ring only after nothing depends on it. Expiring a human session or rotating an agent credential does not erase impact or operation history.
Failed authentications are counted in authentication_failures: one row per surface (http, mcp, human, monitor, token, signal), reason (MALFORMED, UNKNOWN, MISMATCH, EXPIRED, REVOKED, INACTIVE), minute and subject. A row names a tenant and a credential, monitoring source or signal source only when authentication verified that row exists; malformed tokens and unknown selectors are counted without any tenant, address or token material. Responses never change, recording is best effort outside the audited history, and requests refused by the request limits are not counted. uv run execbound failures --tenant <id> lists a tenant's attributed windows and --unattributed lists the rest; credential-list shows each credential's failures in the last 24 hours. These counters are the one class of operational data an operator may purge with the owner connection once they are older than needed, since nothing references them; no automated purge exists.
Retention, backup and recovery states the retention policy (audit, execution, policy and provider evidence are never purged), the four-part backup set, the restore procedure and the verifier that proves a restored database against retained checkpoints; CI rehearses the round trip on synthetic data. No automated backup scheduling exists. Two automated purges do: the scheduled recover pass clears the assistant's questions, steps and answers thirty days after each turn completes (above), and monitor-process removes a Microsoft Entra connection's month-old directory audit observations once no rule reads them (Scout); gateway history, provider journals/tombstones, configuration and evidence-verification keys are interdependent for recovery, and sanitized replay exports are not backups of the whole operational system.
Rotate signing and evidence keys
Both rings are keyed by identifier: continuations and human form state carry the continuation key id that signed them, and provider journals carry the provider's signing key id. Verification looks the id up in the current ring, so rotation is a ring change, never a re-signing.
- Set
key_reload_secondsin the private configuration (for example 30) before the rotation; the gateway then checks the file's modification time at that interval andGET /health/readyreportskeysascurrent,refused,failedordisabled. A check that finds an unreadable or invalid file reportsfailed; a valid file that changes anything exceptactive_key_id,continuation_keysor a connector'sevidence_keysreportsrefusedwith the running keys untouched, because every other change needs a restart. Alert on either. - Introduce the successor: add a new continuation key under a new id and make it
active_key_id, keeping the old key in the ring; for a provider, add the key it will sign with next to the current one in that connector'sevidence_keys. Save the file. New continuations and forms are signed with the new key; everything signed with the old keys keeps verifying. - Switch the provider to its new signing key id in its own configuration when its rollout allows; the gateway already verifies both.
- Before retiring an old key, run
uv run execbound rotation-readiness --tenant <id> --credential-file <workspace administrator file>. It answers a workspace administrator only, because it reads every space's operations; an administrator seated in spaces is refusedINVALID_IDENTITY(decided 8 October 2026). It lists operations stillDISPATCH_COMMITTEDorINDETERMINATEper account (their evidence may arrive signed with the old provider key, and reconciliation needs it) and the latest expiry among pending or approved approvals and unrevoked human sessions (continuations and form state signed with the old continuation key live at most that long, and never more than two minutes past issue for continuations). Wait until those are settled or past. - Remove the old key from the ring and save the file. A proof or token signed with a removed key is refused (
INVALID_EVIDENCE,INVALID_CONTINUATION); a refused proof leaves the operationINDETERMINATE, and restoring the key lets reconciliation settle it from the retained evidence.
The reload rotates keys atomically for the whole process: the continuation ring, the human form-state ring and every connector's evidence ring, with the verifier the gateway settles that connector's evidence with, change together or not at all, under the same independence rules as startup (continuation keys distinct from provider keys, provider families distinct from each other, every key compared as HMAC-SHA256 uses it, so a key with zero bytes appended is the same key) and the same refusal of the executor protocol's published test keys in any ring. Provider dispatch and control tokens do not rotate at runtime. Multi-process deployments rotate each process's file; the ready probe shows which have picked up the change.
Local diagnostic starting points
| Observation | Check |
|---|---|
| Execution/MCP/human route absent | Was only the foundation factory started? Use the full harness or private runtime configuration. |
| Readiness unavailable | Correct runtime role, exact revision, grants/RLS and database connectivity; inspect private diagnostics without posting credentials. |
| Fresh action rejected after a long pause | Fixture context expiring 300 seconds after seeding (the contract default max age; the demo bundle sets no context_settings), credential/session expiry, active policy and assigned scope; seed a new demo. |
| New seed/output refused | Names or paths may already be retained from a run. Use a new namespace/output; do not overwrite evidence. |
| Provider/gateway port occupied | Identify its owner or choose unused seed ports; the harness must not terminate unrelated processes. |
| Provider response lost | Follow reconciliation above; a timeout does not permit resend/refund. |
| Maintained MCP/browser tests unavailable | Install the separate hash-locked target and Chromium/system dependencies from the README. |
| Local role password mismatch | Keep .env paired with the database data; setup deliberately does not reset existing credentials. |
These are entry points for investigation, not a substitute for reading the actual error and reproducing it. Never paste .env, full connection URLs, provider account JSON, token files or browser storage into an issue.
Before a production deployment
Before a deployment that reaches a real vendor, choose and validate hosting, TLS and origin behavior, operator identity provisioning, inventory freshness and ingestion, secret rotation, log handling and observability, backup and restore, and each provider's own guarantees. Keep local development and test URLs in .env, keep a remote deployment's runtime and owner URLs separate, and never run the destructive test suite or the seeders against a deployment's data. The container image and the process contract an orchestrator must express are described in Deployment.