Skip to content

Latest commit

 

History

History
404 lines (346 loc) · 107 KB

STATUS.md

File metadata and controls

404 lines (346 loc) · 107 KB

Overslash — Status

Current state: Phases 1–4 backend complete. Stripe billing live, monitoring stack deployed, dashboard mature (audit log, members, approval queue, billing flows all shipped). Mode A/B collapsed into a single Service + HTTP verb surface (SPEC §8). Public launch is gated on transactional email, human-facing docs, and the legal/compliance surface — see TODO.md.


Agent Tooling

  • PR mergeability Stop hook (.claude/hooks/pr-mergeability-gate.sh, wired in .claude/settings.json): blocks Claude Code task agents from ending their turn until the current branch's PR satisfies all three mergeability gates — CI green (waits up to 10 min for pending checks via gh pr checks --watch), no unresolved review conversations (GraphQL reviewThreads.isResolved), and no merge conflicts (mergeStateStatus != CONFLICTING). When all gates pass, the hook arms gh pr merge --auto --squash so the PR enters the dev merge queue automatically. Capped at N=5 block attempts per turn (tracked via stop_hook_active + a per-session counter under $TMPDIR/overslash-pr-gate/); after the 5th block the hook surfaces the failing gate(s) on stderr and allows the stop so a human can take over rather than looping forever. If there is no PR for the current branch, the hook is a no-op. The 'behind base' state is intentionally NOT gated — the merge queue handles up-to-dateness.
  • Merge queue on dev (repo ruleset id 14770759, .github settings): PRs target dev and are merged via GitHub's merge queue (squash, ALLGREEN grouping, required check ci-ok). ci-ok passes only if every job the change's paths filters call for succeeded: failure, cancelled, and a needed job that was skipped all fail it. The queue rebases each candidate against latest dev and merges when green, removing the agent's responsibility for keeping branches up-to-date with base. dev flows into master via merge commits (no squash) so feature history is preserved on master; the master ruleset (id 14707284) enforces merge-commit-only.

What Exists

What's Built

Phase 1 — Core Service (MVP) ✅

  • Rust/Axum backend with Cargo workspace (crates/overslash-api, crates/overslash-core)
  • PostgreSQL schema with sqlx migrations
  • Full CRUD: orgs, identities, secrets (versioned + encrypted), API keys
  • POST /v1/actions/call �� raw HTTP with secret injection (http pseudo-service)
  • Permission rules (flat per-identity)
  • Approval workflow (create, resolve with allow/deny/allow_remember, expiry loop)
  • Audit trail (all actions, approvals, secret access)
  • Webhook delivery (approval.created, approval.resolved) — HTTPS-only endpoints; legacy http:// subscriptions disabled with disabled_reason: needs_https (migration 124); endpoint-ownership handshake (webhook.verification challenge echo, POST /v1/webhooks/{id}/verify) before any delivery, events held while pending, pre-existing subscriptions grandfathered as verified (migration 125); every attempt signed with a timestamp (X-Overslash-Timestamp + X-Overslash-Signature-V1: v1=<hmac("<ts>.<body>")>, re-signed per retry) — the only signature; the replayable body-only X-Overslash-Signature: sha256= is no longer sent and the SDK's verifyWebhookSignature is gone (D125); SDK verifyWebhook checks v1 with a ±5 min tolerance
  • 8+ integration tests

Phase 2 — OAuth + Service Registry (in progress)

  • OAuth engine (authorization URL, code exchange, token storage, auto-refresh)
  • BYOC credential resolution with fallback chain (identity → org → system)
  • Three-tier OAuth credential cascade (SPEC §7): user BYOC → org-level secrets (OAUTH_{PROVIDER}_CLIENT_ID/SECRET) → system env vars. Org-level tier is managed via Org Settings → OAuth App Credentials (PUT/GET/DELETE /v1/org-oauth-credentials/{provider}). IdP configs (org_idp_configs) default to the same org secrets (migration 032 makes encrypted_client_id/secret nullable; login resolves from the org secrets when NULL), so rotating org credentials propagates to linked IdPs automatically. Overslash-managed Google/GitHub sign-in keeps the env {PROVIDER}_AUTH_* app even when org credentials exist, unless the admin opts the provider in (Use for sign-in action / form checkbox → org_oauth_signin_overrides, PATCH /v1/org-oauth-credentials/{provider}).
  • Connections API (initiate, list, revoke)
  • OAuth connections bind to the owner identity on import/connect (DECISIONS D23) — kernel_create_connection/kernel_import_connection resolve the calling identity to its ceiling user (agent→owner_id) by default, so connections stop accreting on agents and every agent under a user shares one (storage complement to the D22 read path). upgrade_scopes accepts the caller's ceiling owner. Migration 086_connection_owner_identity re-points existing agent-level rows to their owner, owner-wins-deduping against an existing owner connection for the same (provider, account_email).
  • Reconnects update the existing connection instead of adding one. MCP overslash.create_connection takes service (name or id): it re-authorizes the connection that service authenticates with (its pin, else the provider default) in place, unioning the requested scopes and reusing its BYOC client, or — with none — binds the new connection to the service on callback; upgrade_connection_id targets a connection directly and is refused up front (404) unless it is the caller's owner's. The OAuth callback is idempotent on identity + provider + account email + BYOC client, so a plain connect of an already-connected account lands on the existing row when its grant covers the row's scopes (a narrower grant, or a row whose scopes are unknown — an import that named none — gets its own row, so no token is ever narrowed) — for providers where the email identifies one grant (oauth_providers.email_identifies_account, migration 136); Microsoft, Slack, HubSpot, Notion and custom providers keep one row per connect. New read action overslash.list_connections (also updated_at on GET /v1/connections) lets an agent see where a reconnect landed. Dashboard service page: [Reconnect] (in place) and [Connect another account] (bound on callback, no Save), owner-only. Every dashboard connect popup now waits on GET /v1/oauth/flows/{flow_id} (the callback's stamp) rather than diffing the connections list.
  • Account hints on the authorize URL (migration 106_oauth_login_hint_param) — a reconnect returns the user to the account the connection already belongs to instead of whichever provider session the browser happens to hold. kernel_create_connection_for_identity derives the hint from the connection's account_email whenever upgrade_connection_id is set, so every reconnect path inherits it (POST /v1/connections/{id}/upgrade_scopes, the dashboard Reconnect button, and the action handler's reauth_required/missing_scopes minters). Fresh flows accept an explicit login_hint on POST /v1/connections, MCP overslash.create_connection, and as an override on upgrade_scopes. The parameter name is per-provider (oauth_providers.login_hint_param, exposed on GET /v1/oauth-providers/{key}): login_hint for Google/Microsoft and OIDC-discovered custom providers, login for GitHub (the synthetic {login}@users.noreply.github.com label is unwrapped back to the username first), NULL — and therefore dropped, never sent — for LinkedIn/HubSpot/Slack/Notion/Spotify/X/Eventbrite, none of which take a per-user hint.
  • White-label token vault (POST /v1/connections/import, migration 082_connection_token_vault) — partners that own their OAuth run the dance themselves and import the resulting tokens; overslash stores/refreshes/injects them and issues no redirect_uri. The import requires a byoc_credential_id (null → 400); overslash self-refreshes hard-pinned to that client, never the env/org cascade. Auth-recovery for white-label end users is driven by a per-org headless flag (migration 084_orgs_headless, admin-only GET/PATCH /v1/orgs/{id}/headless): for a headless org, reauth_required/needs_authentication/missing_scopes return URL-less envelopes (headless: true, no auth_url/short/upgrade_url, no flow row) so the integration re-runs its own dance and re-imports. The connections.integration_managed flag is removed (migration 085_drop_connection_integration_managed) — it conflated refreshability with flow-ownership; the no-client import mode and the connection.refresh_required webhook are gone. Replaces and removes the per-request redirect_uri override + allow-list (#388/#392), the per-org oauth_redirect_url + use_org_redirect switch (#398), POST /v1/oauth/exchange, the include_raw/raw-authorize-URL surface, and oauth_connection_flows.redirect_uri. See docs/design/white-label-token-vault.md.
  • Global service template registry — OpenAPI 3.1 loader with x-overslash-* alias normalization, search API, and parse-don't-validate pipeline (PR #118)
  • 10 service templates shipped as OpenAPI 3.1: Eventbrite, GitHub, Gmail, Google Calendar, Google Drive, Google Tasks, Resend, Slack, Stripe, X (plus the overslash platform namespace)
  • outlook service — Microsoft Graph mail (Gmail-equivalent): profile, messages (search/read/send/move/delete), drafts (create/send), and mail folders, over provider: microsoft (already seeded). Structured sendMail JSON gives clean approval disclosure without base64 decoding
  • OAuth for external MCP-runtime services — x-overslash-mcp.auth: {kind: oauth, provider: …} resolves the caller's owner connection (D22) into the outbound MCP bearer via the standard oauth_providers/connections machinery (shipped for HubSpot). template_oauth_provider + McpDetail.provider extended so oauth MCP services auto-connect, validate pinned connections, and surface a dashboard connect affordance.
  • slack service — Slack's Web API (slack.com/api/*) with a user token from the slack provider: send_message, update_message, add_reaction, list_channels, read_channel_history, get_thread_replies, get_user, list_users, search_messages. Summaries and disclosures name the channel ('#general') through a conversations.info resolver on every channel-taking action, and get_user names the person (users.info); a channel Slack won't name (a DM, a scope the token lacks) falls back to the raw ID. HTTP resolver misses are now logged (display-param resolver …, with the upstream's in-band error code), so the reason is greppable. Replaces the D24 MCP wrapper, since Slack's MCP server only admits Marketplace-listed apps. Shared-app caveat: Slack caps conversations.history/.replies at 1 req/min and 15 messages for non-Marketplace apps, and an org's own BYOC Slack app is exempt.
  • x-overslash-success (alias success) — a jq predicate over a below-400 JSON body, set under info or per operation, HTTP only. A body that fails it is an upstream error for is_error, audit, response capture and paging, with the real status kept. It is stored on replay payloads and syntax-checked at template register (success_invalid_jq). Slack uses .ok == true.
  • Template/service instance split — templates (OpenAPI 3.1 blueprints with x-overslash-* extensions) + service instances (named, with credentials and lifecycle)
  • Three-tier template registry — global (read-only, shipped OpenAPI) + org (CRUD by org admins) + user (CRUD, gated by user_template_policy) (PR #100)
  • Layered service templates — every org/user template row is a layer (extends/delta, migration 097). A standalone layer holds a full OpenAPI doc; a derived layer holds a delta over a live base, resolved by the fold apply(delta, resolve(extends)). Masks (allowlist/denylist, risk clamp-up, additive disclose, relabel, hidden) are monotonic so containment is structural; extensions add actions/hosts only (no auth, no rebinding). extends is a live pointer (curation tracks upstream); discovery/instantiation/execution all read the effective surface. Pure fold in overslash-core::service_layer, shared walker in services::template_resolve, dashboard layer editor at /services/templates/layer. allow_user_templates → user_template_policy enum (none/restrictive(reserved)/full). (DECISIONS D29)
  • Template validation endpoints — POST /v1/templates/validate (PR #108, struct-level OpenAPI lint, WASM-reusable) + POST /v1/templates/validate-delta (derived-layer delta lint against its resolved base)
  • User-level services always visible to owner and their agents (PR #130)
  • Per-service OAuth scopes declared end-to-end on templates and propagated through the authorization URL (PR #127)
  • Service+action execution (registry-resolved, auth auto-resolved)
  • Service + HTTP verb execution (SPEC §8) — instance + caller-supplied method + (path|url); auth from instance binding, host bounded by svc.hosts. Permission keys derive as {service}:{METHOD}:{path}.
  • connection: <uuid> action calls removed (DECISIONS D14) — closes the host-binding gap; free-form authed calls go through Service + HTTP verb.
  • scope_param on service actions — permission keys use specific args from action params. Accepts a list with per-entry scope labels ([to:recipient, cc:recipient, bcc:recipient]), deriving {service}:{action}:{label}={value} keys; value-only rules still match any label (DECISIONS D40).
  • on_behalf_of for agent-initiated operations (PR #90) — agents create secrets and connections at the owner-user level so sibling agents share them
  • Description interpolation — {param} substitution and [optional segments] in action descriptions
  • Human-readable audit descriptions — interpolated descriptions for the action shape, METHOD host/path for the http pseudo-service, identity_name resolved in audit responses
  • Suggested tiers + derived_keys on approval payloads (2-4 broadening levels)
  • Approval resolution API aligned with spec (resolution + remember_keys + ttl)
  • X.com OAuth with PKCE support
  • Eventbrite OAuth provider support
  • E2E tests against real providers: Eventbrite (OAuth), GitHub (PR #113), Google Calendar (PR #111), Google Drive (PR #107), Gmail (PR #115), Resend (token), X.com (OAuth+PKCE, PR #114), Outlook/Microsoft Graph (OAuth; mock e2e in CI + #[ignore] live test)
  • sqlx compile-time query checking enforced across all repos
  • SQL content policy (D42/D43) — services/metabase.yaml (API-key auth; run_query/export_query at risk: dynamic) + a Postgres-exact classifier (pg_query/libpg_query behind the default-off sql_policy feature; release builds and the e2e stack enable it, the Windows binary fails closed). x-overslash-sql-field nominates the SQL param and its body path (string params nest, object params are descended into); read-only SELECTs run as read-class per referenced table (table={label}/{relation} keys, where {label} is the alternation group {name,id} so a grant written on either the database's name or its upstream id matches), everything else elevates to write and bubbles approval on the mutation targets (table_mut={label}/{relation} + mutation-shaped all-tables sentinel — a remembered read grant never authorizes writes, and "read anything, write only scratch" is expressible); column=/column_star= deny screening overrides even the auto_approve_level bypass. Verified against a live Metabase + Pagila (make metabase-e2e). The template also manages saved questions: create_card / update_card (risk: write) and delete_card (risk: delete), with the card's SQL as a primary disclosure — a card's SQL later runs through run_card at read class outside the parser, so the save is its SQL review.
  • Shortcut service template — services/shortcut.yaml, 35 curated actions over Shortcut's REST API v3 (stories, epics, iterations, projects, tasks, comments, plus the workflow/member/team/label metadata the writes need). Workspace API token injected as a bare Shortcut-Token header; every story, epic and project write resolves its id to the entity's name for the approval, and is scoped to that id so approving one story is not approving all of them. list_projects is the only action that surfaces a project with no stories filed under it yet. Shipped vendor icon (simple-icons:shortcut). Its search actions motivated the link pagination extension below.
  • Holded service template — services/holded.yaml, 36 curated actions over Holded's REST API v2 (invoices, credit notes, estimates, contacts, leads, purchases, payments, treasury, plus the tax/payment-method/chart-of-accounts metadata the writes need). Per-module-scoped API token injected as an Authorization: Bearer header; every per-invoice write is scoped to its id and resolves that id to the document number for the approval, so approving one invoice is not approving all of them. send_invoice additionally scopes by recipient, and create_invoice_payment discloses an omitted amount or treasury account rather than letting // swallow it. No delete-class actions: a fiscal document is cancelled (cancel_invoice) or corrected with a credit note, never removed. get_usage is the credential probe. Shipped vendor icon, cropped from Holded's own logotype since simple-icons carries no mark for it.
  • Figma service template — services/figma.yaml, 24 curated actions over Figma's REST API v1 (document tree, nodes, rendered images, image fills, metadata, version history, team/project navigation, published components and styles, design variables, dev resources, and comments). OAuth2 with per-operation minimum scopes, so a connection granted only the read scopes keeps every read action; Figma's legacy blanket files:read is deliberately not requested. Every comment and dev-resource write is scoped to its file_key and resolves that key to the file's name for the approval — except the two bulk dev-resource writes, whose file_key lives at an array-indexed body position the dotted-path grammar cannot address, so they disclose the whole array instead and say in their description that a grant covers every reachable file. get_file defaults depth to 2 because the undefaulted call returns every node in the file, past the response cap and past anything jq could rescue. get_me is the credential probe. Shipped vendor icon (simple-icons:figma). First provider to need oauth_providers.refresh_endpoint — see below.
  • Langfuse service template — services/langfuse.yaml, 33 curated actions covering both Langfuse deployments. 21 target the Cloud v4 API: observations, the Metrics v2 query DSL, scores, versioned prompts, datasets, dataset items and experiment runs. The other 12 are the self-hosted read surface: list_traces, get_trace, sessions, list_observations_self_hosted, get_observation, list_scores_self_hosted, get_score, get_metrics_self_hosted, and dataset runs and run items. Self-hosted Langfuse (checked against v3.225.11) gates the v4 reads behind a v4 write mode, so they answer 404 or 501 there, while Cloud sunsets the older paths on 2026-11-16. Each self-hosted description opens with "Self-hosted Langfuse read API." and each Cloud read names its counterpart. tests/langfuse_self_hosted.rs holds every old-path action to that marker and to GET, so no write to a dying surface can ship. list_traces defaults fields to core,metrics because Langfuse returns every group, io included, when it is absent. One template for both is a stopgap: TECH_DEBT.md proposes versioned template families. Auth is a Langfuse project key pair sent as HTTP Basic, and it is the first template outside email to split a credential: the public key is a plain instance config value (readable, and the instance's identity, so two Langfuse projects are told apart without calling both) and only the secret key costs a vault entry, joined by a jq template into Authorization: Basic base64(pk:sk). The Metrics v2 query and the three structured filter params are the first shipped use of contentMediaType: application/json + contentSchema outside telegram's MCP tool — the wire value stays the string Langfuse wants, while the declared shape lets a malformed query be refused here instead of after an approval was spent. list_observations and list_experiment_items bound their page at 25 rather than Langfuse's own 50, because one page of the io field group is whole prompts and completions and is past the response cap. update_prompt_labels discloses that moving production is a deployment; delete_traces discloses every id, which is what makes a bulk delete reviewable. list_projects is the credential probe — /api/public/health is unauthenticated and would answer 200 for a key pair that does not work. No shipped icon: simple-icons carries no Langfuse mark, so the manifest entry is pending and the dashboard renders the letter tile.
  • A provider may refresh where it does not mint — oauth_providers.refresh_endpoint (migration 120, nullable). RFC 6749 puts the authorization-code and refresh grants at one endpoint and every provider seeded before Figma does too, so NULL keeps them exactly where they were. Figma documents POST /v1/oauth/refresh, separate from POST /v1/oauth/token; with 90-day access tokens, refreshing at the wrong URL would have worked perfectly until a quarter after the first connection.
  • link pagination reads the next URL from a header or the body — next.from names a dotted body path holding a whole next URL (Shortcut's next, Zendesk's next_page, Twilio's next_page_uri), and next.param names the one query key that URL may introduce although page one never sent it. The RFC 8288 header stays the default when from is absent, so no shipped template moved; an MCP tool may now declare link provided it declares from.

Phase 2.5 — Dashboard (in progress)

  • SvelteKit dashboard scaffolded (/dashboard/) with TypeScript, Tailwind CSS, adapter-static
  • Agents view redesigned per Figma (PR #105) — identity hierarchy tree with user node as immutable root, inline agent management
  • Templates dashboard UI (PR #112) — global / org / user template list with Template Editor entry point and provider dropdown (PR #124)
  • Services view — create from template, connect credentials, browse instances (Create Service surfaces user-level BYOC via has_user_byoc_credential, PR #131)
  • Standalone Provide Secret page (PR #89) with User Signed Mode for attributed secret provisioning (PR #109)
  • Developer Connection Tool — interactive API explorer with unified execution flow
    • Service/action selector with method and risk badges
    • Auto-generated parameter forms from action schemas (text, number, enum dropdowns)
    • Supports defined actions, custom HTTP requests, and raw HTTP (http pseudo-service)
    • Response panel with JSON syntax highlighting, headers table, request inspector
    • API key management with localStorage persistence
  • 2026-04-10 review corrections applied — doc-level (PR #96) and dashboard-level (PR #99)
  • Build/quality — zero-warning vite builds enforced (PR #125); Inter + Roboto Mono self-hosted via @fontsource-variable (PR #129)
  • Per-user secret vaults (D119, migration 133) — names are unique per vault (each user's, plus the org's), not per org. Service bindings store secret paths (org/<name> / <user id>/<name>; input also takes <handle>/<name> and bare names). Create/update may only bind the writer's own vault (or, for admins, the org vault); unchanged bindings are kept. A user-level service never resolves another user's vault. Inline (Mode A) secrets reach only the caller's own vault. /v1/secrets* take ?owner= / ?scope=org (admin-only for other vaults). The dashboard shows <handle>/<name> labels, and admins get an Org-wide toggle on New Secret.
  • Binding policies (D122, follow-up to D119/D120) — one policy module per stored reference (secret path, connection pin, BYOC pin, instance template) owns its write and read checks. Changes:
    • An instance resolves its owner's template, never the caller's.
    • A user-level instance reads the org vault only when it lands on the org/global template's own endpoint. The dashboard endpoint field warns when a custom URL drops an org secret.
    • A BYOC pin must be the connection owner's app for the same provider, at create, import and every refresh.
    • Views show only usable pins.
    • Owner-blind getters are *_any_owner and fenced by the binding_guard test.
  • Secrets dashboard view — /secrets list (non-admins see their own vault, admins every vault — SPEC §6) and /secrets/{name} detail (versions table, used-by, reveal modal, update-value, restore-version). Backend: GET /v1/secrets + GET /v1/secrets/{name} extended with owner_identity_id (now stored as an explicit column on secrets, set on first insert, preserved across versions via COALESCE), created_at/updated_at, versions[], used_by[]. New endpoints POST /v1/secrets/{name}/versions/{v}/reveal (audit-logged as secret.revealed) and .../restore (audit-logged as secret.restored). Reveal / restore / detail stay session-only. GET /v1/secrets (list) accepts bearer auth and returns a narrow {name, version_count, last_rotated_at} shape to agent/sub-agent callers — values never leave the vault.
  • Audit Log dashboard view (/audit, PR #238) — full-text + filter search over the audit trail with identity path, ref + UUID search, deep-linkable rows, and CSV export.
  • Approval queue redesign — distilled approval card + queue UI (PR #250), auto-call-on-approve made universal with result piped to the webhook (PRs #239, #257), select-requesting-agent on deep-link with auto-call feedback (PR #245), shortened approval URLs via oversla.sh (PR #249).
  • Members page (/members) — org membership listing + admin actions for the multi-org world.
  • API Explorer — component library + interactive page; surfaced from service-instance views via a "Try it" button (PR #247).
  • Per-agent MCP Connection card (PR #204, fixed in #211) — DCR / OAuth consent / disable / revoke surfaced inline on /agents/{id}; URL-driven /agents/<id> rationalization (PR #214).
  • Org service keys mintable from Org Settings (PR #221).
  • Template catalog UX pass (PR #237) + secret-name autocomplete with vault picker (PR #236) + /services/templates/import route scaffolded for OpenAPI bulk import.
  • Responsive shell for tablet + mobile (PR #240); favicon (PR #241); /docs/claude-code/ quickstart route.
  • Service icons (D63) — templates carry info.icon, implicit from the template key whenever Overslash ships an asset by that name, so 18 of the shipped templates declare nothing and still render a mark. Built-in assets are generated from simple-icons into assets/service-icons/ by make service-icons, baked into the API binary, and served unauthenticated at GET /icons/{key}.svg with an ETag. Templates may instead declare an https:// URL; nothing else reaches a browser. Surfaces as an absolute icon_url on the template and service-instance responses, rendered by ServiceIcon.svelte over the existing letter tile. Slack, LinkedIn, Eventbrite and Outlook ship no mark yet (each absent from simple-icons) and keep their monograms. Four glyphs are authored by us rather than sourced — email, deepwiki, overslash and http — the last a globe, since Mode A is not a vendor and the mark stands for "any URL you supply".
  • Agent icons (D70) — an agent renders as the logo of the MCP client bound to it over three colours hashed from its own id, so agents sharing a client stay distinguishable. Resolved server-side onto GET /v1/identities as icon_url / icon_stripe / mcp_client_label; the client is matched by normalized substring over clientInfo.name → DCR client_name → software_id, and anything unrecognised (or unbound — the API-key case) falls back to a bot glyph, so an agent mark is never absent. Marks ship in the existing assets/service-icons/ set under a client_ prefix that keeps them out of the implicit builtin:<template key> rule; client_vscode and client_chatgpt are pending (neither is in simple-icons) and render the letter tile. No column and no migration — both halves derive from data already stored, and there is deliberately no picker. Rendered by AgentAvatar.svelte in the agents tree, the detail header, the audit log's Agent column and the Live Map.
  • Preview-deployment OAuth handoff (PRs #242, #248) — server-side auth-state instead of cookies so Vercel preview URLs can complete OAuth.

Phase 3 — Identity Hierarchy + Hierarchical Permissions

  • Parent/child identity relationships with parent_id, depth, owner_id columns
  • IdentityKind expanded: user, agent, sub_agent
  • Hierarchy validation: users have no parent, agents require user parent, sub_agents require agent/sub_agent parent
  • inherit_permissions dynamic resolution: when set, identity inherits parent's permission rules at query time (live pointer, not copy); chain walks upward through continuous inherit_permissions=true ancestors
  • Ancestor chain query (recursive CTE) and children listing endpoints
  • MCP OAuth 2.1 agent enrollment — /oauth/authorize pauses and routes through /oauth/consent (new-mode creates an agent under the signed-in user; reauth-mode rebinds a re-registered DCR client_id to the existing agent). Bespoke /v1/enrollment-tokens and /v1/enroll* flows retired — migration 042 drops enrollment_tokens + pending_enrollments.
  • MCP enrollment org-scoping (D26, migration 095, design mcp-enrollment-org-scoping.md) — on a corp subdomain (<slug>.api.overslash.com) the enrolled agent's org is derived from the subdomain, not the session: a mismatched warm session is re-authed through the org IdP, the fast-path binding lookup is org-scoped (mcp_client_agent_bindings UNIQUE now includes org_id), and DCR clients are org-stamped (oauth_mcp_clients.org_id, NULL at root) so a client can't be replayed on another org's subdomain and the Org Settings → MCP Clients list/revoke is scoped to the admin's org. Root apex stays the multi-org hub (enrollment follows the session org, corp or personal — unchanged).
  • Agent-facing SKILL.md at repo root, served at /SKILL.md by the API (cloud Vercel rewrite + self-hosted Axum route), documents the OAuth path + the overslash mcp login workaround for MCP clients without native OAuth support (e.g. OpenClaw).
  • Standalone "Provide Secret" page (/secrets/provide/req_{id}?token=jwt): JWT-scoped, single-use, no-login secret submission. secret_requests table (migration 027), POST /v1/secrets/requests (mint), public GET/POST /public/secrets/provide/{req_id} (verify + submit), SvelteKit standalone route.
  • User Signed Mode for the Provide Secret page (migration 031): opportunistic session binding (if the visitor's oss_session cookie is present and matches the request's org, their identity is recorded on secret_versions.provisioned_by_user_id and the secret_request.fulfilled audit row is attributed to them instead of the target identity), plus an org toggle allow_unsigned_secret_provide (on by default — defaults to true so existing orgs keep current behavior) exposed via new GET/PATCH /v1/orgs/{id}/secret-request-settings. When the toggle is flipped off, newly-minted requests carry secret_requests.require_user_session = true at mint time and reject anonymous submission with 401 user_session_required. The toggle is forward-only — outstanding URLs keep the policy they were issued under. Cross-tenant sessions are silently ignored. Dashboard: org settings page exposes the toggle; the provide page switches to credentials: 'same-origin' and renders a "Signed in as …" banner or a "Sign in to continue" gate as appropriate.
  • GET /v1/identities/{id}/children, GET /v1/identities/{id}/chain
  • Sub-agent idle cleanup with two-phase archive — last_active_at touched per request, background loop (60s) archives idle sub-agents (revoking API keys with revoked_reason='identity_archived' and expiring pending approvals), then purges archived rows past the retention window. Parents wait for live children before archiving or purging. POST /v1/identities/{id}/restore un-archives within the window and resurrects auto-revoked API keys; manually-revoked keys are untouched. Archived identities return 403 identity_archived from the auth middleware. Idle timeout (subagent_idle_timeout_secs, 4h–60d) and retention (subagent_archive_retention_days, 1d–60d) are configured per-org via PATCH /v1/orgs/{id}/subagent-cleanup-config.
  • Hierarchical permission chain walk (SPEC §5): call_action walks the requester→user chain; each non-user level must authorize via own rules or inherit_permissions
  • Approval bubbling: approval identity_id stays the requester; current_resolver_identity_id tracks who must act now; explicit bubble_up resolution and per-org approval_auto_bubble_secs background sweep advance the resolver up the chain
  • Resolver authorization: only the current resolver, an ancestor of it, or an org-admin (no-identity) key can resolve a pending approval
  • "Allow & Remember" rule placement targets the requester's closest non-inherit_permissions ancestor (inclusive), not the requester itself when it just borrows permissions

Phase 4 — Groups (Layer 1 Permission Ceiling)

  • groups, group_grants, identity_groups tables (migration 020)
  • Group grants reference org-level service instances with structured access levels (read/write/admin)
  • Raw HTTP is the synthetic http service instance (one system-managed singleton per org) — group access uses the standard grant mechanism with read/write/admin levels mapping to verb risk
  • auto_approve_level per-grant (none/read/write/admin, D53) — a second ceiling on the same ladder, bounded by access_level: calls at or below the level skip Layer 2 entirely. Defaults to none on a new grant, read on the auto-created Myself grant. Deny rules still bind on auto-approved mutations.
  • Full CRUD API: POST/GET/PUT/DELETE /v1/groups, grants, and member management. GET /v1/groups reports is_member for the calling identity (resolved through their ceiling user)
  • Org-level service creation (user_level: false) requires a non-empty groups array on POST /v1/services, and at least one named group must be one the creator belongs to — an org-level instance has no Myself group, so without a grant nothing can reach it. The dashboard's /services/new form surfaces the picker and blocks submit until it's satisfied
  • Group ceiling check in action execution (Layer 1, before permission key check)
  • Users gated by groups only — they are their own approvers (skip Layer 2)
  • User-owned service instances bypass ceiling for the creator
  • Service visibility filtered by group membership (GET /v1/services)
  • Approval resolution validates remember_keys against group ceiling
  • Backward compatible: no groups assigned = no ceiling enforced (permissive)

Directory Group Sync (D107)

  • directory_groups, identity_directory_groups, group_directory_sources + the effective_identity_groups view (migration 123)
  • Opt-in per IdP config: org_idp_configs.group_sync_enabled (default off) and group_claim (default groups)
  • At sign-in, an org's own IdP's group claim is mirrored into directory_groups; membership in identity_directory_groups is reconciled to match the claim exactly. Sync never writes identity_groups, so a manual assignment cannot be clobbered
  • Claims are read from /userinfo and the ID token (Entra releases groups only in the latter). The ID token's signature is unverified per OIDC Core §3.1.3.7; its nonce is checked against the login's — see TECH_DEBT.md
  • A directory group confers nothing until an admin maps it: POST /v1/groups/{id}/directory-sources. System groups refuse the edge
  • GET /v1/directory-groups, GET /v1/directory-groups/{id}, GET /v1/directory-groups/{id}/members, GET|POST|DELETE /v1/groups/{id}/directory-sources, GET /v1/groups/{id}/member-origins. DELETE /v1/groups/{id}/members/{identity_id} answers 409 for a directory-derived member
  • Never runs on Overslash-managed sign-in (D12), only for kind = 'user' identities, and never fails a login
  • Dashboard: Directory groups section on /org/groups, a detail page at /org/directory-groups/{id}, Directory sources card on the group detail page, via <group> badges with remove suppressed, per-IdP Group sync column on /org. Pills are added through the shared PillPicker (a + that opens a searchable dropdown)
  • Google Workspace Directory source (D110, D113): one service account per instance with domain-wide delegation for admin.directory.group.readonly, from OVERSLASH_GOOGLE_DIRECTORY_SA_KEY or _FILE (Terraform: enable_google_directory_sync; runbook docs/runbooks/google-directory.md). No per-org keys. An org connects by signing in with Google as a Workspace admin (POST /v1/google-directory/connect, finished on /auth/callback/google with a gdir: state): hd → domain (unique per instance), verified email → impersonated admin; the flow is single-use and bound to the starting session. org_google_directory_configs (migrations 129, 131). Writes source = 'google_directory' rows. Per-user pull spawned at sign-in (never blocks it), sweep every sync_interval_hours (default 8) and Sync now (at most one queued) through a leased worker (services/google_directory_worker.rs, FOR UPDATE SKIP LOCKED). Direct membership only; bounded to the connected domain; a partial listing revokes nothing. GET|PUT|DELETE /v1/google-directory, POST /v1/google-directory/{connect,sync}. Dashboard: Org Settings → Google Workspace (/org/google-directory), which shows the client ID and scope to add in admin.google.com
  • Not built: SCIM push, a periodic sweep for the claim source, nested Google groups, general group nesting

Multi-Provider OIDC Authentication

  • Generic OIDC provider support — /auth/login/{provider_key} and /auth/callback/{provider_key} replacing Google-specific routes
  • OIDC Discovery — auto-discover IdP endpoints from .well-known/openid-configuration, through ssrf_guard on every hop; https required, plain http to loopback only; upstream errors are logged, never echoed
  • GitHub social login — GitHub userinfo + email API integration
  • Per-org IdP configuration — org_idp_configs table (CRUD API at /v1/org-idp-configs)
  • Env var vs DB precedence — env vars (GOOGLE_AUTH_CLIENT_ID, GITHUB_AUTH_CLIENT_ID) take precedence over DB config
  • Multiple IdPs per org simultaneously
  • User provisioning by email domain matching (configurable per IdP config)
  • Profile update on subsequent logins (name, avatar synced from IdP claims)
  • Group claim → directory groups at sign-in, opt-in per config (see Directory Group Sync)
  • Available providers endpoint — GET /auth/providers?org=<slug> for login page
  • Backward-compatible Google login routes preserved

Phase 4 — Rate Limiting

  • Two-tier rate limiting: User bucket (shared by all agents) + optional per-identity caps
  • Rate limit configuration API: PUT/GET/DELETE /v1/rate-limits with scopes: org, group, user, identity_cap
  • Resolution chain: per-user override → group default (most permissive) → org default → system fallback
  • Standard headers on all responses: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
  • 429 Too Many Requests with Retry-After header when exceeded
  • Dual storage backend: Redis/Valkey (distributed) or in-memory DashMap (single-instance fallback)
  • Fail-open on Redis errors; health endpoint exempt from rate limiting
  • Fixed window counter algorithm with configurable window size
  • Dashboard sessions (own per-identity bucket, sized from the user budget) and MCP access tokens (owner-user bucket + identity cap) are metered, not only osk_ keys
  • /oauth/*, /.well-known/oauth-* and /mcp throttled by middleware/ingress_rate_limit.rs: per client IP on the handshake, a stricter per-IP cap on POST /oauth/register, per MCP client on /mcp (OAUTH_, OAUTH_REGISTER_, MCP_RATE_LIMIT / _RATE_WINDOW_SECS)
  • A deny logs once per bucket per window (the request that crosses the limit), under a fleet-wide 60 lines/min budget; drops count in overslash_rate_limit_deny_log_suppressed_total
  • Per-IP throttles key IPv6 clients by /64

Org Slug Subdomains (<org>.app|api.overslash.com)

  • Dual subdomain surface: *.app.overslash.com (browser dashboard via Vercel wildcard) and *.api.overslash.com (programmatic / MCP / OAuth-AS via Cloud Run + GCLB). MCP clients hit <slug>.api.overslash.com/mcp directly, browsers hit <slug>.app.overslash.com and call the API cross-origin. The subdomain middleware accepts either suffix and dispatches the same way; .well-known/oauth-authorization-server and the MCP WWW-Authenticate challenge return per-subdomain issuer URLs so RFC 8414 discovery works on every org subdomain.
  • GCLB stack (infra/modules/api-lb/): one global IPv4 address (an IPv6 one is declared behind api_lb_enable_ipv6, on in prod tfvars, pending apply + AAAA records per lb-hardening.md) + wildcard managed cert (api.overslash.com + *.api.overslash.com) + serverless NEG → Cloud Run. URL map is a single catch-all — subdomain_middleware does the per-org dispatch in-process. Replaces the old google_cloud_run_domain_mapping (single-domain only). Toggled via enable_api_lb=true in tfvars.
  • One sign-in resolver (crates/overslash-api/src/services/org_signin.rs): a single answer to "which providers can this org sign in with, on whose OAuth app", read by /auth/providers, /oauth/authorize, /v1/org-idp-configs and resolve_auth_credentials. An org_idp_configs row claims its provider key — disabled means that provider is off for the org, not that Overslash's app takes over — and the Overslash-managed providers fill the unclaimed keys when allow_overslash_managed_signin is on. Before this, each handler re-derived the rule and they disagreed: /oauth/authorize had never learned about managed sign-in and returned 503 login_required for orgs that used it instead of a dedicated IdP.
  • Per-org default IdP (migration 049, org_idp_configs.is_default + partial unique index): each org designates one enabled IdP as the default. /oauth/authorize on a corp subdomain bounces unauthenticated callers straight through it; with no default it goes straight to a lone provider, or to /login for the picker when there are several. Surfaced via is_default on /auth/providers and the create/update payload.
  • Corp-subdomain login bounces target the app host: anything that starts a login for an org (the /oauth/authorize IdP bounce) redirects to https://<slug>.<app-apex>/…, not a host-relative path. The __Secure-oss_auth_* cookies carry Domain=SESSION_COOKIE_DOMAIN (.app.<apex>), so a login kicked off on <slug>.api.<apex> — the host the AS metadata advertises as authorization_endpoint — would have its cookies rejected by the browser and die at the callback. Deployments with no APP_HOST_SUFFIX keep the relative path.
  • Trust-domain isolation on corp subdomains (DECISIONS.md D12 + its 2026-05 amendment): an org's own org_idp_configs rows are the default path; the Overslash-managed providers are available only where the org opted in via allow_overslash_managed_signin, and admission stays a separate gate (pending invite identity, or managed_signin_allowed_domains). Env-var-managed Overslash login keeps working at the root apex for personal-org sign-up / org-creator bootstrap.
  • Return-host preservation across the apex-bound OAuth callback: when login originates on <slug>.app.overslash.com but the OAuth provider's pre-registered redirect_uri lands at the API apex, the callback reads oss_auth_org + app_host_suffix to build an absolute redirect back to the original subdomain so users don't get stranded on the apex.
  • X-Forwarded-Host trust: subdomain middleware reads XFH first, falls back to Host. GCLB forwards Host unchanged but XFH support keeps Vercel rewrites and any future proxy chain working with one code path.
  • X-Forwarded-For trust (DECISIONS D102): ClientIp, which feeds every audit ip_address and per-IP throttle, takes the rightmost address not covered by OVERSLASH_TRUSTED_PROXY_HOPS / _PROXIES. The Vercel rewrite hop is vouched for by a shared secret stamped by dashboard/middleware.ts (OVERSLASH_TRUSTED_PROXY_SECRET), which also names the client in x-overslash-client-ip. XFF left of that hop is never read, because Vercel forwards the browser's own. With nothing configured, the socket peer is the client. prod: hops 1 + GCLB CIDR; dev: hops 1. The secret is set in GSM and Vercel and enabled on both envs (enable_trusted_proxy_secret = true).
  • Wildcard CORS: DASHBOARD_ORIGIN accepts https://*.app.overslash.com syntax (single-DNS-label predicate match — evil.attacker.app.overslash.com doesn't squeak through).
  • Dashboard: org settings IdP card adds a "Default" column and "Set default" / "Unset default" actions. Login page on a corp subdomain auto-redirects when a single default IdP is set, preserving the next= query param so MCP-driven OAuth bounces resume cleanly.
  • Vercel: dashboard/vercel.json adds wildcard host matchers for *.app.overslash.com (and *.app.dev.overslash.com), forwarding REST/auth paths to api.overslash.com. /.well-known/* and /mcp on app.* redirect to the matching api.* so programmatic clients always land on the canonical issuer.
  • New env vars: API_HOST_SUFFIX (alongside the existing APP_HOST_SUFFIX); SESSION_COOKIE_DOMAIN typically .app.overslash.com for cross-subdomain cookie sharing.

Multi-Org Auth

  • Data model (users, user_org_memberships, orgs.is_personal, identities.user_id) live (migration 040). Design: docs/design/multi_org_auth.md. Trust-domain rule codified in DECISIONS.md D12.
  • Session JWT carries user_id alongside sub (identity) + org. Legacy tokens without user_id keep working until they expire; extractors resolve the human via identities.user_id as a fallback.
  • Subdomain middleware parses Host → RequestOrgContext::{Root, Org} and attaches it to request extensions. SessionAuth / AuthContext enforce jwt.org == subdomain.org and return 401 org_mismatch otherwise — the dashboard routes that through /auth/switch-org. SINGLE_ORG_MODE=<slug> bypasses the middleware for self-hosted single-org operators.
  • Login flow rewire — root-login provisions an Overslash-backed user + personal org on first sign-in; org-subdomain login provisions an org-only user via one of two admission paths picked by the org admin: (a) the legacy domain-whitelist gate (org_idp_configs.allowed_email_domains, non-match → 403 not_permitted_by_org_idp), or (b) the invite-gated path (migration 066, orgs.allow_overslash_managed_signin = true — default for new corp orgs) where every sign-in must match a pending org_invites row regardless of IdP, non-match → 403 not_invited. No cross-IdP account linking — each IdP is its own trust domain.
  • Corp-org creation — POST /v1/orgs creates the org + an admin identities row + a regular admin membership for the caller (when the session carries a user_id). The creator's Overslash-level login keeps working against the org indefinitely, whether or not the org later configures its own IdP. Gated by ALLOW_ORG_CREATION (returns 403 org_creation_disabled when disabled).
  • Account surface — POST /auth/switch-org, GET /v1/account/memberships, DELETE /v1/account/memberships/{org_id} (personal-org + last-admin guards; leaving archives the caller's identity subtree and revokes its API keys and org sessions, same as admin removal, and rescopes the session to the personal org when leaving the current org). Dashboard: Leave on Account and Org Settings → General; Remove from org in the Members drawer for admins. /auth/me/identity now returns user_id, personal_org_id, memberships[], invitations[].
  • Invitee-side invitations (D52) — GET /v1/account/invitations, POST /v1/account/invitations/{id}/accept, POST /v1/account/invitations/{id}/decline. Keyed on the caller's IdP-verified users.email, so it lists invitations from orgs they haven't joined; accept links the pending identity + creates the membership in place (refused with org_requires_idp_signin for orgs running their own IdP), decline archives it as invite_declined. Surfaced as a Pending invitations section at the bottom of the dashboard sidebar, above the org switcher.
  • A display name travels with the identifier (D72) — impersonation's X-Overslash-As-Name and an email field on POST /v1/identities / PATCH /v1/identities/{id}. A user root that would otherwise be labelled from their email local-part (alice@acme.com → alice, in the members list, on approval cards and in audit_log.actor_name) is provisioned with the real name instead, and a member who has never signed in can have theirs corrected. The header takes a literal ASCII value or the RFC 8187 UTF-8''<pct-encoded> form, since fetch cannot put a non-ASCII byte in a header at all; it applies to the user root only (an agent's path segment already is its name) and is refused with a 400 aimed anywhere else. Adoption is the boundary on both surfaces: once external_id is set the IdP owns the name and the address, so the header rename becomes a no-op and an email patch a 409. The rename is applied after the ACL cap, so a refused impersonation cannot leave one behind on its way to a 403, and an unchanged name writes nothing — this runs on the auth path of every request. One live identity per email in an org holds across all three admission paths (invite, impersonation, admin create); a duplicate is a 409.
  • Session cookie honors a configurable Domain (SESSION_COOKIE_DOMAIN, typically .app.overslash.com) so the same session is shared across subdomains.
  • Every auth cookie is Secure and prefixed (CASA 2.3.1), all built in crates/overslash-api/src/cookies.rs: with SESSION_COOKIE_DOMAIN set the names are __Secure-oss_session / __Secure-oss_auth_{nonce,verifier,org,next}; without it they are __Host-… (no Domain, Path=/, so the auth-state cookies lose their Path=/auth scoping there). The Vercel preview handoff always sets __Host-oss_session, which the reader also accepts. The unprefixed oss_session is never read and is cleared on login and logout — the rename logged every user out once. Self-hosting requires HTTPS: browsers only keep a Secure cookie over plain http on localhost / loopback, so a deployment served on http://<lan-ip> can no longer sign in.
  • Server-side dashboard sessions (CASA 2.2.1–2.2.3, migration 130). The session cookie is still a 7-day JWT, but it carries a jti naming a user_sessions row, and middleware/session_gate.rs checks the row on every request before any extractor or rate limiter sees the cookie — a revoked cookie is stripped (the request is anonymous, so it never spends its former owner's rate-limit bucket) and cleared on the response. Logout, a new sign-in in the same browser, an IdP-reported email change, an admin email rewrite, admin removal and archive all revoke. Switch-org / consent / connect-gate / org-create re-scope the same row rather than minting a new one. Account → Sessions lists live sign-ins with End session and Sign out all other sessions (/v1/account/sessions). Checks are cached in Valkey (30 s live / 1 h dead, deleted on revoke); without REDIS_URL every check reads Postgres. Session JWTs with no jti are honoured only under 24 h, so every pre-existing cookie was signed out once on deploy.
  • Admin step-up (CASA 3.3.1 / 2.4.1, migration 137, D136). Sessions record their sign-in method, provider, time and the IdP's amr/acr. AdminAcl / InstanceAdminAuth mutations from a dashboard session in a shared org need an IdP sign-in through one of the org's providers from the last ADMIN_STEP_UP_MAX_AGE_SECS (1 h), else 403 step_up_required and the dashboard's Confirm it's you prompt, which re-runs the IdP login with max_age=0. The sign-in time is the ID token's auth_time (Google logins request it); an OIDC IdP that omits it yields auth_time_missing, and only GitHub (not OIDC) falls back to the callback time (ADMIN_STEP_UP_CALLBACK_TIME_PROVIDERS). A magic link qualifies only on a deployment with no IdP. /v1/org-idp-configs mutations now require admin (they had no check). Enrolling an MCP client as an org admin (/v1/oauth/consent/{id}/finish) takes the same step-up, because an admin's agent passes AdminAcl through its owner (D137).
  • Dashboard — OrgSwitcher in the sidebar footer (grouped Personal / Orgs, no per-row badges), /account page with leave-membership action, login page renders a corp-org empty state when no IdP is configured, /org hides IdP + OAuth-credential cards on personal orgs and shows a "configure an IdP" warning banner on corp orgs without one enabled.
  • Migration 041 drops the pre-040 identities.email global UNIQUE, keeping a plain lookup index. Multi-org requires the same human's email to appear on multiple identity rows; the (org_id, user_id) partial UNIQUE from 040 continues to prevent double-admission per human per org.

Billing (Stripe)

  • Cloud billing surface — /v1/billing/{config,geo,checkout,portal}, /v1/orgs/{id}/subscription, signed POST /v1/webhooks/stripe. Checkout creates the Stripe customer + subscription, provisions the org on checkout.session.completed, then routes the session to the new org via redirect_for_org (PRs #213, #197).
  • Geo-aware pricing — /v1/billing/geo reads CF-IPCountry (falls back to X-Country-Code / USD) and returns EUR for EU member states.
  • Stripe customer portal — POST /v1/billing/portal returns a hosted self-service link; surfaced from /billing/portal.
  • Automatic tax wired (customer_update=auto) for cross-border compliance.
  • free_unlimited org tier + instance-admin self-service create (PR #217) for design partners + self-hosted accounts.
  • E2E coverage — crates/overslash-fakes/src/stripe.rs ships a full Stripe fake driven by a Playwright Checkout flow (PR #231); tests/billing.rs + tests/free_unlimited.rs exercise the REST surface.
  • Dashboard — /billing/new-team (Checkout entry), /billing/portal, /billing/success, CreateOrgModal deferring to checkout when cloud_billing=true.

Monitoring & Observability

  • OpenTofu module infra/modules/monitoring/ (PRs #200, #205, #207, #272) deploys 5 GCP dashboards — overview, api-use, actions-and-oauth, cloudsql-use, business — plus P0/P1/P2 alert policies. Deployed in both envs (measured 2026-10-27, 14 policies + 1 uptime check each): API down on the /health uptime check + API 5xx > 1% (P0); API CPU/memory, gateway slow-request ratio, Cloud SQL CPU/disk, background-task staleness, OAuth refresh failure ratio, upstream error ratio, webhook terminal failure ratio, data integrity, unexpected secret access (P1); API 4xx (P2). Prod P0s page PagerDuty (EU) + email. Dev's API Down fires every night during the Cloud SQL stop, by design. Runbook: uptime-and-alerts.md.
  • OTel sidecar exports metrics into GMP (instance-label collision fixed in #272).
  • Data integrity sweep. Every metrics-exporter tick also runs overslash_db::integrity::sweep: 15 read-only SQL invariants (crates/overslash-db/src/integrity/*.sql) over stored references the binding policies (D119, D122) and the relationship gates (#723, #737) would refuse — secret bindings outside the owner's or org vault, bare bindings, pins to a colleague's or another org's connection, pins on org-level instances, BYOC clients across users/orgs/providers, agent-owned secrets, cross-org owners and identity chains, templates from another tier or org, and pending approvals whose resolver is in another org or off the requester's chain. Each emits custom.googleapis.com/overslash/business/integrity_violations{invariant} (zeros included) and logs offending rows' ids at WARN; a failing sweep is skipped without losing the business metrics. [P1] Data Integrity Violation (any point > 0, or the series absent 30 min) is gated off behind integrity_alert_enabled until the metric descriptor exists; the pin↔template provider check is not swept (provider isn't in SQL). Runbook: docs/runbooks/data-integrity.md.
  • The gateway slow-request alert is a PromQL ratio over overslash_http_request_duration_seconds, not a Cloud Run p99. It excludes /v1/events/stream and every upstream-proxy path, whose durations are set by EVENTS_STREAM_MAX_CONNECTION_SECS and CALL_TIMEOUT_MS rather than by the gateway. It replaced a run.googleapis.com/request_latencies P0 that paged on healthy traffic — that metric carries no route label, so it could not be filtered in place.
  • PromQL alerts are gated and ship off. api_latency_alert_enabled, oauth_refresh_alert_enabled and upstream_error_alert_enabled all default to false and are listed explicitly in both infra/env/*.tfvars; GMP rejects a policy whose metric descriptor does not exist yet. Confirm the descriptor, then flip the flag.
  • Notification channels: email channel auto-provisioned when alert_email is set (infra/env/dev.tfvars already wired); PagerDuty channel auto-provisioned when pagerduty_integration_key is set. Slack / PagerDuty integration keys are not yet bound (see Launch Blockers in TODO.md).
  • Public status page at https://status.overslash.com (Better Stack) with independent HTTPS uptime checks for api.overslash.com and app.overslash.com. Runbook: docs/runbooks/status-page.md.
  • JSON-format logs with message/span/textPayload surfaced through make logs (PR #198).

Actions surface — unified

  • Mode A (raw HTTP under a synthetic instance) collapsed into the http service singleton (PR #265, DECISIONS D15).

  • Mode B (connection: <uuid> calls) killed; replaced by SPEC §8 Service + HTTP verb (PR #261, DECISIONS D14). Single CallRequest shape across all execution paths.

  • Typed reauth_required + needs_authentication error envelopes (PR #259); structured 400 + dry-run POST /v1/actions/validate (PR #256); MCP tools/call surfaces the same typed envelopes (PR #263).

  • Stable webhook envelope with routing headers (PR #258); connection lifecycle events emit webhooks (PR #260).

  • Re-fetchable call results (DECISIONS D61): a verbose: false render that truncates stores the full ActionResult (encrypted, call_results table, migration 111) and stamps _full_result.download_url into the same envelope as the cropped body. Redeemed through the existing GET /v1/downloads/{token}, which now branches on call_result_id to serve stored bytes instead of replaying upstream. CALL_RESULT_MAX_BYTES (1 MB, 0 disables); shares DOWNLOAD_TOKEN_TTL_SECS. Side effect: deliver: "url" now effectively works for OAuth-authenticated services on the re-fetch path, since a result-backed token dials nothing. Complements D57, which mints a token for the same request when the transport cap trips and no body ever existed; this one serves stored bytes and dials nothing. Truncation itself is unchanged — priority-aware compaction is in TODO.md §3.

  • Proxy uploads — bytes move into a service (DECISIONS D76): an MCP action declaring x-overslash-upload is served by the gateway rather than forwarded, and mints a one-shot upload_url on POST|PUT /v1/uploads/{token} (anonymous, per-IP throttled, upload_tokens + media_descriptors, migration 116). Redemption streams the request body straight through to the byte route on the instance's own origin with the bearer re-resolved from the vault; nothing about the container's URL or token reaches the caller. Optional sha256 / size_bytes bind the capability to one file — size is cut mid-stream, a hash mismatch answers 422 and withholds the stored reference. Intercepted at both dispatch sites, including approval replay (StoredMcpCall.upload), because a risk: write upload reaches the replay path first for any gated agent. UPLOAD_TOKEN_TTL_SECS (900), UPLOAD_MAX_BYTES (100 MiB); audited as action.uploaded with declared and measured. services/whatsapp.yaml gains upload_media; send_file / send_audio_message resolve media_path through the new ledger (resolve: {source: media}), so an approval's primary row names the file instead of hashing it — including for references seen on a plain download_media with no deliver: "url". Bytes pushed to the container out of band stay un-enriched and fall back to the raw path.

  • Gateway-staged uploads — attachments for HTTP actions (DECISIONS D114): overslash:upload_file mints a single-use URL on the same POST|PUT /v1/uploads/{token} route, but the bytes land in Overslash's own staged_uploads (migration 132, AES-256-GCM, 24h TTL) because the target — the stateless overfwd Mailbox Gateway — has nowhere to keep them. A body property marked x-overslash-staged-upload takes [{upload_id}]; resolution swaps each for the stored descriptor (so approvals disclose the real filename/type/size/digest and replay payloads hold no bytes), and the send path inlines {filename, content_type, content_base64} next to credential injection on every dial path. services/email.yaml send gains attachments (≤ 20, 10 MiB total). Bounded by STAGED_UPLOAD_MAX_BYTES (10 MiB, clamp 20), per-identity (50 MiB / 50 files) and per-org (200 MiB) quotas counted from mint, and a per-replica buffer semaphore (STAGED_UPLOAD_CONCURRENCY, 4); over quota is a 429 whose force: true retry evicts only the caller's own oldest, unpinned uploads. STAGED_UPLOADS_ENABLED switches minting off.

  • Layered call timeouts (DECISIONS D56): timeout_ms per call, x-overslash-timeout_ms per action, x-overslash-default_timeout_ms per service, call_timeout_ms / max_call_timeout_ms per org, CALL_TIMEOUT_MS / CALL_TIMEOUT_MAX_MS per deployment. 504 carries timeout_source. The previously-unbounded inline path is now bounded; streaming bounds time-to-first-byte and guards the transfer with a per-chunk idle timeout. CALL_TIMEOUT_MS is pinned to 110000 in Cloud Run for the rollout, to be removed once audit percentiles confirm nothing legitimate lives above 30s.

  • Generic pagination (DECISIONS D75): x-overslash-pagination on an operation or MCP tool maps the corpus's six spellings of page size and six of continuation onto one vocabulary — page_size: {param, default, max} plus next: {style, param, from} over four styles (cursor, offset, page, RFC 8288 link). page_size.default seeds the parameter's own default: at compile time, so the bound reaches the wire through apply_defaults and shows up on /v1/search rows without a second mechanism. A paged result carries _pagination: {has_more, next: {service, action, params}} — the delta for the next call, in the action's own parameter names — derived from the raw body and headers, so a jq filter or the compact render cannot cost the caller the page. /v1/search rows gain paginated: true; ActionSummary gains a pagination object and the service actions tab a "pages" pill; the response_too_large 502 hint names the page-size parameter. The async worker and approval replay carry the declaration and the call's arguments on the stored payload (StoredPagination), since a stored call is a resolved request with no action key left to look it up from — the same answer D56 gave the timeout cascade. The gateway never follows a page. Builds on D74. The shipped corpus is swept: every list action in services/ either declares the key or is named in registry::tests::shipped_list_actions_declare_pagination's allow-list with the upstream reason it cannot page, and next_page unwraps the MCP result envelope (D55's structuredContent-else-text-block projection) so an MCP template's paths address the tool's own payload. Two upstreams stay allow-listed rather than widening the vocabulary: Stripe continues with the id of the last row on the page (an array index, which the dotted grammar refuses) and email.search has a page size with no continuation at all — both are now bounded, and say so in their descriptions.

  • List-heavy actions have a middle gear (DECISIONS D57). GET /v1/search action rows now carry params — the action's caller-supplied contract (name/type/required/description/enum/default), required-first then alphabetical, instance-config params excluded — so a declared paging parameter is discoverable instead of folklore. filter is declared on the overslash_call / overslash_read MCP tool schemas and forwarded (bare jq string lifted into {lang, expr}), and now actually applies on the MCP-runtime and platform-runtime forks, which previously accepted it and silently ignored it. A response_too_large 502 carries a pre-minted download_url + expires_at for the same request (best-effort: OAuth-injected services and inline raw-HTTP credentials still get the plain 502), and the compact truncation hint leads with narrowing rather than verbose=true. services/metabase.yaml gains limit/offset/archived on search, the real f enum + model_id on list_cards, and new popular_items / recents actions; export_query now declares a responses: block so it compiles to a binary response type instead of being buffered against the size cap.

  • Async (non-blocking) action calls (DECISIONS D62), behind ASYNC_EXECUTION_ENABLED (default off): execution: "sync" | "async" on POST /v1/actions/call, a 202 accepted envelope carrying execution_id, and a claim-and-lease worker that runs the call off the request path. Executions become a resource of their own (GET /v1/executions, GET /v1/executions/{id}, POST /v1/executions/{id}/cancel), with a new executions event topic, a dashboard list + detail page, MCP get_execution / cancel_execution, and overslash get-execution. This is what the D56 over-ceiling 400 now points at. Gated calls are covered too (DECISIONS D66): the request's mode is stamped on approvals.execution_mode, and triggering the approved replay — manually or via auto_call_on_approve — queues it for the worker and answers 202 instead of dialling inline, with the synchronous claim excluded by an AND request IS NULL predicate so the two triggers can never both reach the upstream. Async is bounded by ASYNC_CALL_TIMEOUT_MAX_MS (default 900000) rather than the 110000 sync ceiling, because that number exists to sit under a proxy's request cap and no proxy is counting an async call.

  • execution: "hybrid" (DECISIONS D68), same flag: the call runs off the connection from the first byte, and the connection waits on it for HYBRID_HANDOFF_MS (default 5s, per-call handoff_after_ms). Beat it and the caller gets the ordinary called envelope — now carrying execution_id — and miss it and the caller gets the same accepted envelope async returns. The row is inserted already claimed before anything is dialled, so it is durable either way and no worker can take it; a hybrid row whose replica dies is failed as hybrid_instance_lost, never requeued, because the upstream already received the request. HYBRID_MAX_INFLIGHT (default 32) caps concurrent hybrid jobs per replica, and over it a call degrades onto the ordinary async queue with the same envelope. origin distinguishes hybrid from async_call. A gated hybrid call is queued exactly as a gated async one is.

  • An action can declare the mode a call defaults to (DECISIONS D73): x-overslash-wait-mode on an operation or MCP tool is rung 2 under the request's own execution, with x-overslash-handoff_after_ms beside it. The caller wins in both directions, so execution: "sync" against an action declaring hybrid still runs inline. Where a deferred mode cannot be honoured — prefer_stream, deliver: "url", return_url, runtime: platform, a binary response, or the flag being off — the template's declaration is silently demoted to sync while a caller's is still a 400, the same clamp-versus-refuse split timeout_ms makes; demotions are counted by overslash_wait_mode_demoted_total{reason}. The accepted envelope gains execution_mode_source so a 202 nobody asked for can name the rung that chose it, and ActionSummary.wait_mode surfaces the declaration in the dashboard before anyone calls the action. No migration — approvals.execution_mode already admits all three values.

  • Execution results are no longer readable org-wide. GET /v1/approvals/{id}/execution checked only org scope, so any identity-bound credential in the org could read any execution's upstream body; GET /v1/approvals/{id} and the approvals list embedded the same body. All four paths now go through services::execution_access (admin, or the requester, or write access plus ancestry over the requester or the resolver), and a viewer outside that set gets result_redacted: true instead of the payload.

  • Display-param resolvers are cached (DECISIONS D64). An x-overslash-resolve answer — display string and the scope-derived canonical value — is reused for a bounded window instead of costing an authenticated round trip on every /v1/actions/call. Valkey-backed when REDIS_URL is set, process-local DashMap otherwise; every operation is bounded at RESOLVE_CACHE_TIMEOUT_MS and fails open to a live resolve. The lookup runs before credentials are assembled, so a full hit skips the vault decrypt on HTTP and build_client (vault reads + blocking DNS) on MCP. TTL is cache_ttl: per resolver, else RESOLVE_CACHE_TTL_SECS (300s), clamped by RESOLVE_CACHE_SCOPE_TTL_MAX_SECS when the resolver canonicalizes a permission key; failures cached separately for 30s. Values are keyring-encrypted, and the key carries a credential fingerprint so gmail's userId: me cannot serve one user's address to another. services/gmail.yaml pins cache_ttl: 3600 on the 19 me → profile resolvers.

MCP — additional surface (beyond the OAuth transport already documented)

  • Tools annotated for client UX and overslash_call split into overslash_read (read-class fast-path, prompt-skip) + overslash_call (general) (PR #235).
  • Metaservice bridge: service-instance kernels (PR #244), template-authoring (PR #246), create_connection (PR #253), request_secret kernel with signed-provide handshake (PR #252), capability-gated connection settings on OAuth consent (PR #215).
  • Fan-out search per instance, actionable template-vs-instance errors (PR #243); nested OAuth for upstream MCP servers (PR #220); MCP Inspector CORS (PR #232).
  • Elicitation approvals are on by default. Migration 123 replaces mcp_client_agent_bindings.elicitation_enabled with elicitation_opted_out and moves the default into code (ELICITATION_DEFAULT_ENABLED); existing bindings carry over as opted-out, so the installed base is untouched. The consent-time capability gate is gone, and deliberately: oauth_mcp_clients.capabilities is written only by initialize, which needs a token issued after consent, so a freshly-registered client_id always looks incapable there and gating on it pinned every first connect to off. The capability check now lives only in elicitation_eligible, at request time. Opt out per agent on the Agent detail page or at connect time on /oauth/consent; the opt-out survives reauth because consent reads the prior binding before the upsert.
  • An unanswered elicitation is no longer a denial. Only MCP decline resolves the approval as denied; cancel, a JSON-RPC error answer, a poll timeout, a disconnect and a sweeper reap all retire the elicitation row, leave the approval pending, and close the original tools/call with the same pending_approval envelope the non-elicitation path returns (one shared constructor, so the bytes cannot drift). This is what makes default-on safe: headless / --print Claude Code auto-cancels in ~6ms because it has no dialog to render. A cancelled row then suppresses elicitation for that agent for 120s (CANCEL_COOLDOWN, indexed by idx_pending_mcp_elicit_agent_cancelled), because every gated call mints a fresh approval so a per-approval guard would never bind. Callers that do not send Accept: text/event-stream are never upgraded to SSE at all.
  • /mcp is dual-era: MCP 2026-07-28 alongside 2025-06-18 (D103). A request carrying _meta["io.modelcontextprotocol/protocolVersion"] (or server/discover) is served sessionless: server/discover, resultType + serverInfo on every result, ttlMs / cacheScope on lists, header–body mirroring enforced (-32020), unknown versions refused (-32022). Approval elicitation there is a multi round-trip request — input_required with the form and a signed requestState, answered by the client retrying the call — over the same pending_mcp_elicitations row, so cooldown, auto-call suppression and the unanswered-dialog fallback are unchanged. Claude Code 2.1.282+ negotiates it on direct HTTP servers; claude.ai connectors and Codex 0.157.0 stay on 2025-06-18. URL-mode elicitation is wired too — see the next bullet.
  • URL-mode elicitation (D104, routes/mcp/url_elicitation.rs). For clients declaring elicitation.url (Claude Code 2.1.282+ on 2026-07-28, Codex on 2025-06-18), links the user must open are handed over through the client and the tool call finishes when the browser flow does: needs_authentication / reauth_required / missing_scopes replay the call after the OAuth callback stamps oauth_connection_flows.completed_at; request_secret and create_service setup/connect links report the original result marked url_elicitation: "completed"; a permission gap whose form dialog is unavailable (opted out, or form not declared) is handed over as the approval page and calls the action once approved. Declined / cancelled / failed / timed-out (300s) hand-offs return the original body with a url_elicitation note. 2025 connections get one SSE stream (elicitationId, notifications/elicitation/complete; answers cross replicas via mcp_url_elicitations, migration 126); 2026-07-28 gets input_required + a retry that waits on an SSE keep-alive stream.
  • URL hand-offs always answer, with a reason (D106). Browser-side refusals end the wait at once as declined + url_elicitation_error: the provider's Deny (access_denied; the callback now accepts error= redirects instead of 400-ing at the extractor), the consent page's Cancel (POST /connect-authorize/cancel), the provide page's Deny (POST /public/secrets/provide/{id}/decline → secret_requests.declined_at). Callback failures are failed with a coarse reason (oauth_connection_flows.failure, migration 127). An auth link that can no longer be opened (refused, failed, or opened then abandoned) is re-run so the agent gets a fresh auth_url, never the consumed one; a link that timed out unopened goes back as it is. The consent Cancel runs the same gate check as confirm. Legacy clients get notifications/elicitation/complete on every browser-side end. A 2026-07-28 retry with an expired requestState (≤24h) gets the envelope / timed_out fallback instead of an error, never an action; modern waits run on their own task so a dropped client cannot strand an approval's auto-call hold.
  • pending_mcp_elicitations is swept in two phases on the existing 60s background loop (issue #600). mcp_elicitation_reap cancels pending/claimed rows whose originator pod died — until they go terminal they keep suppressing auto-call on their approval — and mcp_elicitation_purge then deletes terminal rows, which is what bounds the table: final_response holds a full ApprovalResponse snapshot, disclosed_fields included. Both windows derive from the originator's own 300s poll ceiling plus SWEEP_GRACE_SECS (default 60), one knob now shared with the executions and async orphan graces, so no window can be configured below the ceiling it guards. Both predicates lead with status, so they ride idx_pending_mcp_elicit_status (status, created_at) rather than seq-scanning.

Agent self-setup defaults (D79)

  • A first-level agent (kind = 'agent', depth = 1) is seeded at creation with four allow rules — overslash:{manage_services_own,manage_templates_own,manage_connections_own,request_secrets_own}:* — so the SKILL.md "bootstrap a service from a template" flow runs with no approval clicks. repos::org_bootstrap::bootstrap_agent_in_org holds both guards (the org flag and the kind/depth test); the REST identity route, MCP enrollment consent, and impersonation provisioning all call it unconditionally.
  • Gated by orgs.default_agent_self_setup (migration 117, default true), on the existing execution-settings endpoint and surfaced as the Agent defaults card on /org. Read at creation time only — flipping it never touches an existing agent, and there was no backfill. Disclosed on both agent-creation surfaces (/agents create modal, /oauth/consent enrollment form).
  • Never widens to the _share/_publish half. kernel_update_service and the by-id branch of kernel_get_service gained the ownership guard that previously lived only in the REST route — without it a seeded agent could rebind any non-system instance in the org by id, url included.
  • Discoverability shipped alongside: overslash_search rows for an un-connected service carry auth.setup (the ordered calls that make them callable), the handshake instructions and search description say the agent may run those itself, and credential_missing / secret-backed needs_authentication carry self_serve beside hint_url.
  • pending_approval envelopes carry next_step (get_result | call_pending), so an agent stops guessing and stops hitting 409 execution has already completed.
  • Backfill (D80). POST /v1/orgs/{id}/agent-self-setup/backfill grants the four anchors to every live first-level agent lacking them — admin-only, audited, idempotent, and refused with a 409 while the org toggle is off. Surfaced as a Grant to N agents button beside the toggle on /org, behind a confirm dialog. Revocation stays per agent; there is no bulk undo.

Service setup links + credential probe (D83)

  • POST /v1/services auto-mints a signed, single-use setup link for every unbound per-instance credential slot and returns it as setup.setup_url — the twin of the connect.auth_url an OAuth template already gets. skip_credentials: true opts out. The three mint paths (REST POST /v1/secrets/requests, the MCP request_secret kernel, this auto-mint) now share one services::service_setup::mint.
  • Migration 118 adds service_instance_id + credential_key to secret_requests, both-or-neither by check constraint. Fulfilling such a request writes the vault secret and binds the instance's credential slot via OrgScope::bind_credential_slot (a single jsonb_set, so two outstanding links on one instance cannot erase each other). The pair is validated against the template at mint time; fulfilment carries only a capability token and re-derives nothing.
  • GET /public/services/setup/{req_id} backs the standalone /services/setup/[req_id] page — the service-shaped sibling of /secrets/provide/[req_id], submitting to the same POST endpoint. Registered in both dashboard gate lists (+layout.ts for the session, +layout.svelte for the shell).
  • x-overslash-test (unprefixed test:) nominates one read action per template as its credential probe; declared on 20 of the 23 shipped templates — the three without one are deepwiki (every tool needs a repo name, and it authenticates with nothing), overslash itself (runtime: platform, which Ext::Test is deliberately not read on), and the hidden fixture test_email. Validation: at most one per template, risk: read, params covering every required one without a default.
  • POST /v1/services/{id}/test runs it through the ordinary call path — no approval bypass — and returns a verdict with status, latency and the truncated upstream error, never the body. A transport failure is failed, not a gateway 502. Owner-or-admin gated.
  • The three pages reached from outside the dashboard — enrollment consent, secret request, service setup — share one Organization / Signed in as block (RequestIdentityBox.svelte). Uneditable text on the two token-gated pages (the signed URL names one org); the enrollment switcher stays, passed in as a slot. ProvideMetadata gained org_name so the public pages can answer "which company is asking".
  • Dashboard: the create wizard stops on a "Check it works" step instead of navigating away (covering the OAuth "Connect & create" path too), the service detail page's credentials tab gains a Test button, and the setup page runs the probe on submit for a signed-in visitor. Shared TestResult.svelte + SecretValueField.svelte.
  • Knock-on: the overslash_search setup hint collapses from "create_service, then one request_secret per slot" to a single step naming the field the URL arrives on.

Setup links never silently replace a credential (D85)

  • service_setup::mint refuses a link whose secret_name already names a live vault secret: secret_name_conflict (409), with one conflicts[] entry per slot carrying credential_key, secret_name and current_version, plus a hint. Covers all three mint surfaces, since they share that one function. A slot's vault name is the template's default_secret_name and mixes in nothing per-instance, so before this the second instance of a template aimed its link at the first one's credential and fulfilment versioned over it silently.
  • force: true mints anyway, and says what it supersedes: setup.warnings[] on a create, a scalar warning on request_secret / POST /v1/secrets/requests. The audit row carries force, so a deliberate replacement is on the record. Binding an existing secret (credentials: {slot: name}) is untouched and remains the way to share one — it mints no link and overwrites nothing.
  • kernel_create_service runs the check before the instance row is written, so a refused create leaves no orphan; mint's own check stays as the backstop for any future caller.
  • Both public pages read the live version at load (overwrites_version on the provide metadata) and warn that saving replaces it. Not a block: the person holding the link is the one who knows whether it is the same credential. The mint-time check cannot see a secret created after the link — recording the intent on the row and re-checking at fulfilment is deferred.
  • Instance names, same defect from the other side: the create path's catch narrows from any constraint to the two name indexes (a bad connection_id FK used to be reported as "already exists"), kernel_update_service gains the same catch (a rename onto a taken name was a 500), and both name the holding row — above all an archived one, which keeps its name while the default service list hides it. No force here: nothing is being replaced.

Draft until verified (D86)

  • The probe now gates the instance rather than describing it. Migration 119 adds a fourth service_instances.status, pending_setup: created by a setup flow, credential not yet proven. Not callable (every name-resolution query already filtered status = 'active') and absent from overslash_search — but still reachable by the probe, which addresses the call by service_id on an any-status lookup.
  • POST /v1/services/{id}/activate runs the probe and promotes on ok (or not_supported). Every outcome is a 200 — a red verdict is the answer, not a failure to answer. ?force=true is the override and runs no probe — which is what the dashboard's "Activate anyway" calls, rather than PATCH /status, so a forced activation still emits service.activated for an agent blocked on it. Owner-or-admin, like /test; deliberately not folded into /test, which an org admin may run against instances they do not own.
  • The gate defaults on exactly where a probe runner exists — template declares a probe, slot unbound, owner present, no skip_credentials, i.e. precisely when a setup link is minted. verify: true/false on create_service forces it either way; verify: true on a probeless template is a 400. An agent cannot lift the gate itself (it is not an ancestor of its own owner-user), which is why a create it could not verify is never gated.
  • pending_setup is not a valid target of PATCH /status, only a source. That asymmetry is what lets service_setup_draft_purge delete every such row on age alone, after MAX_LINK_TTL_SECS + sweep_grace measured from created_at. The purge cascades outstanding setup links and leaves the vault secret standing (mint_bundle stores under the template's default name, shared across instances). PATCH /status also gained the audit row it never had, flagging bypassed_verification.
  • Setup links are always minted require_user_session = true, overriding the org's allow_unsigned_secret_provide in one direction only: an anonymous fulfilment cannot run the probe it triggers, so it would leave the instance accepted-but-never-live.
  • Agent contract: secret_request.fulfilled gains service_status, a new service.activated event on a new services topic is the terminal signal (its audience includes secret_requests.requested_by, since the waiting agent is a descendant of the owner and chain walks up), and the search setup hint emits a "finish the one you have" step for a template whose gated instance the caller cannot see.
  • Dashboard: the wizard's post-create step is a decision — Edit and retry (an inline panel that takes a credential value, which the wizard could never do before), Leave as draft, or Activate anyway with wording split three ways off the verdict. The setup page activates on submit and names who must finish when the visitor is not the owner. Services list and detail page carry the status; setup-outcome.ts gains not_live and failureKind.
  • Fixed on the way past: runProbe mapped any non-OK to a red failed, so a non-owner's 403 rendered as "the upstream rejected your credential" over a key nothing had tried. Now denied, which is already the amber "never asked" verdict.

Parameter schemas, JSON-carrying fields, and scope extractors (D90)

  • Nested schemas are carried. A parameter's properties/items reach the compiled action instead of being dropped — one openapi::extract::shape::lower_shape for all three lowering sites, capped at 5 levels, no $ref, degrading to the bare object/array rather than failing. 38 shipped params picked up a shape with no template edited, including the 8 (holded's invoice lines, gmail/outlook's message envelopes) that had been authoring a full schema no caller ever saw.
  • Enforced with the same three checks, at every depth. Required present, unknown key rejected, string enum respected, naming the field by its full path (createRequest.objects[0].objectType). No type rejection at any depth. additional-properties relaxes the nested checks, a sub-schema may open itself with additionalProperties: true (read as a bool or a schema), and neither can re-close a level its action opened.
  • Two live bugs fixed by writing the shapes down. A JSON string sent to an array param was comma-split into quoted fragments and sent upstream with no error raised anywhere; and HubSpot's prose put objectType on the request rather than on each object, so the template's Object type disclose filter read a key that is never present and every HubSpot write approval showed a blank object type. Two integration tests encoded the same mistake.
  • The corpus is swept, and stays swept. shipped_structured_params_declare_their_shape in registry/corpus_tests.rs refuses a new undeclared object/array, and a second assertion fails when an ALLOW_MISSING entry stops excusing anything — it caught a stale one on its first run. What remains opaque is Notion's recursive block union and type-keyed property maps, discriminated unions, and platform actions, whose {name: type} param map has nowhere to put a sub-schema.
  • Agents and operators both see it. /v1/search rows carry the shape recursively, capped at 3 levels and 24 nested nodes per parameter with a truncated flag; the dashboard gets it uncapped, and the API Explorer renders a JSON skeleton placeholder, a collapsible field list, and live validation mirroring the server's checks exactly.
  • contentMediaType: application/json on a string param says its wire value is a serialized document (services/telegram.yaml's params_json). Structure is serialized once, in coercion, above the approval and the replay payload; a string the caller supplied is never re-encoded, because serde_json re-sorts keys on a round trip. x-overslash-redact may address a path inside one — the only one of the four dotted-path walkers taught to descend, and the only one where not descending was a lost redaction rather than a missing feature.
  • scope_param gains an extract jq, closing "object-array recipients can't be scoped". outlook:send_mail is now gated per recipient across all three headers. Evaluation stays in overslash-api and key derivation in overslash-core, joined by a typestate that makes "forgot to evaluate" unrepresentable. A failed extractor mints scope_error={label}/{param} and suppresses the :* fallback — the hole being a partial failure, where the entries that resolved kept the call ungated on the one that did not. The sentinel is approvable but structurally not rememberable: no rung offered, an explicit key covering only one refused by name, and a bare Allow & Remember degraded to a one-shot allow.
  • Closed alongside: scope values never went through sanitize_key_component, although the matcher documents that as an invariant — so {a,b} expanded into alternations and a grant for one branch covered a call naming the literal. Extracted values get the full sanitization; plain values get only the three alternation metacharacters, because the full version collapses /, which is live in every shipped GitHub key.

Not Yet Built

Launch blockers — tracked in TODO.md:

  • Transactional email subsystem (billing receipts + welcome + webhook DLQ digest; approvals and secret-requests stay in dashboard/webhook only).
  • Corp-org invite flow (D12-compatible) and corp-subdomain login empty-state.
  • Human-facing documentation site (concepts + REST reference + per-template quickstarts).
  • DPA.
  • Documented manual GDPR request process (export + hard-delete handled by hand at launch; automation deferred).
  • Master-key rotation runbook + tested rotation; Postgres PITR restore drill.
  • CASA readiness — the annual assessment Google requires for the restricted Gmail/Drive/Keep scopes the system OAuth client requests. Gap assessment against CASA Specification v2.1.1 is in docs/compliance/casa/: 6 of 55 requirements are gaps, and neither of the two live vulnerabilities it found is still open. Cross-tenant API-key minting is closed (the org comes from the credential, and migration 121 enforces the pair); unrestricted SSRF on the action-execution path is closed (action execution and webhook delivery resolve, check and pin every target through ssrf_guard, re-running it on each redirect hop). Outbound action traffic requires TLS (4.1.1, D96): services/outbound_tls.rs refuses plain http for Mode A, instance/org endpoints and MCP urls — at write time, at resolve and on every transport hop — except to an OVERSLASH_SSRF_ALLOWED_CIDRS range; the MCP OAuth upstream endpoints are still to follow. Boot interlocks (6.2.1, 1.2.1; config/boot_policy.rs) refuse to start with DEV_AUTH in production, a non-boolean DEV_AUTH anywhere, or a weak SECRETS_ENCRYPTION_KEY / SIGNING_KEY outside a local checkout (OVERSLASH_ENV, which the container image defaults to prod); production clamps a debug RUST_LOG to info, and /health / /ready no longer return the database error. Remediation is tracked in TODO.md §1.6.
  • PagerDuty (or Slack) integration key bound to infra/modules/monitoring/.

Dashboard residuals (carry-overs from review cards 504a7 / 20ae2 / 2e268):

  • IdP config edit UI (backend PUT /v1/org-idp-configs/{id} already supports it — see TECH_DEBT.md §3).
  • Notification bell dropdown in the top bar.
  • Archived sub-agent list + restore button + per-org cleanup config form (backend shipped).
  • Per-agent permission management UI (rules, scopes, "Allow & Remember" review/edit).
  • Canonical OVERSLASH_DASHBOARD_URL env wired into approval URLs.
  • Toggle Switch design-system component (ToggleSwitch.svelte lives but not adopted everywhere).
  • /account profile editing (name + avatar).
  • Org webhook management UI.

Backend / API:

  • Approval visibility scoping (?scope=actionable vs ?scope=mine).
  • Webhook payload: include gap_identity and can_be_handled_by on approval events.
  • User-to-org template sharing (propose / approve / deny; card 7e5ee).
  • OpenAPI bulk import UX completion (/services/templates/import route scaffolded; polish + overlay defaults pending).
  • Ship 11 more service templates to hit the top-20 target (currently 9 + overslash namespace).
  • Audit events on creator-admin add/remove on POST /v1/orgs and DELETE /v1/account/memberships/{org_id}.

CLI + MCP — Surface Restructure (OAuth transport)

  • Single binary overslash replaces the old overslash-api bin (crates: overslash-cli, overslash-mcp).
  • Subcommands: serve (REST API only, cloud mode), web (REST + embedded SvelteKit dashboard, self-hosted), mcp (stdio↔HTTP shim), mcp login (OAuth 2.1 onboarding).
  • MCP over Streamable HTTP + OAuth 2.1 — POST /mcp on the API, gated by Authorization: Bearer. Two single-credential modes: user JWT (aud=mcp, minted via /oauth/authorize → /oauth/token) or static osk_… agent key. Dual-credential model is gone. Full design in docs/design/mcp-oauth-transport.md.
  • Authorization Server endpoints live in overslash-api:
    • GET /.well-known/oauth-authorization-server (RFC 8414) and GET /.well-known/oauth-protected-resource (RFC 9728).
    • POST /oauth/register (RFC 7591 DCR, public clients / PKCE only), GET /oauth/authorize (OAuth 2.1 §4.1 + PKCE, bounces through IdP login via ?next=), POST /oauth/token (authorization_code + refresh_token grants with single-use rotation + replay detection), POST /oauth/revoke (RFC 7009).
    • Registered clients are visible + revocable in Org Settings → MCP Clients.
  • overslash mcp is a thin stdio↔HTTP pipe: reads ~/.config/overslash/mcp.json ({ server_url, token, refresh_token?, client_id? }), forwards stdin frames to POST /mcp, auto-refreshes on 401 once when a refresh_token is present.
  • overslash mcp login runs the standard OAuth Authorization Code + PKCE flow against /oauth/authorize (browser + 127.0.0.1 one-shot listener), persists the resulting token, prints the editor config snippet.
  • Four tools exposed by POST /mcp, each carrying MCP 2025-06-18 annotation hints (readOnlyHint/destructiveHint/idempotentHint/openWorldHint) so clients can present overslash_search/overslash_read/overslash_auth without a confirmation prompt and surface overslash_call as destructive:
    • overslash_search → GET /v1/search — unified service/action discovery (§10) with keyword + Jaro-Winkler fuzzy + optional local pgvector embeddings (bge-small-en-v1.5). Hybrid ranker; results fan out one row per configured instance with the callable name in top-level service, the underlying template for traceability, and hoisted account_email/secret_name. Env kill-switch OVERSLASH_EMBEDDINGS=off + boot-time pgvector preflight — falls back to keyword+fuzzy transparently on vanilla Postgres.
    • overslash_read → POST /v1/actions/call with require_risk=read — read-only fast-path. Same service/action/params shape as overslash_call's fresh-call mode; the action handler rejects with HTTP 400 when the resolved action's risk is not Read. No approval_id field (resume is by definition write/destructive). overslash meta-service: only the read-class actions (list_pending, get_result, get_events, list_services, get_service, list_templates, get_template) are reachable through this tool.
    • overslash_call → POST /v1/actions/call — full surface (read/write/delete) plus approval resume via approval_id.
  • Real-time event stream — GET /v1/events/stream?topics=approvals,connections,secrets,activity,services (SSE, D45, docs/design/event-stream.md). Fixed 30s connection ceiling with Last-Event-ID resume per SPEC §10. Backed by a durable events table (BIGSERIAL id = the resume cursor) fanned out cross-replica by an AFTER INSERT pg_notify of the id alone + a per-replica PgListener → tokio::broadcast; no Redis. Events carry a frozen audience uuid[] mirroring mine/assigned/actionable, so the stream is identity-scoped rather than org-broadcast (deliberately narrower than GET /v1/approvals, which still has no ACL gate). Auth is the existing AuthContext (session cookie, osk_ key, or MCP JWT); identity-bound credentials only, no query-param token. Approvals carry three "needs a decision" events — approval.created, approval.bubbled (user or auto-bubble sweep) and the derived approval.pending (fires after both; the inbox signal, stream+webhooks only, no audit row) — and one terminal event, approval.resolved, whose status distinguishes a human verdict (allowed/denied), a cascade, and the background expiry sweep (expired, resolved_by: "system", plus an approval.expired audit row). A single services::events::emit seam feeds the stream and webhooks so payloads cannot drift — it replaced all 8 webhook_dispatcher::dispatch call sites — and adds secret_request.created/.fulfilled (token/URL excluded from the payload). Per-replica caps: 4 streams/identity, 64/org, refused 429 + Retry-After. Dashboard consumes it via one shared EventSource (stores/events.svelte.ts): the approvals queue, detail view and notification bell update live, with their previous polling retained as fallback and the queue's "live" chip finally wired to real connection state. The expiry sweep returns rows rather than a count so it can emit per approval; it stays bounded by a MATERIALIZED-CTE LIMIT per statement and a capped number of batches per 60s tick. The services topic D86 opened for service.activated also carries service.created / .updated / .deleted (D87), emitted from the create and manage kernels — so the MCP create_service path raises them too — and from the status and delete handlers. Those three use for_service, which is for_connection: the owner's chain plus the actor. Deliberately not D86's for_service_setup, which additionally reaches downwards through secret_requests to the agent blocked on a setup link — one topic, two audiences, because only activation is something a caller waits on. The payload names the instance, its owner and its status and stops there; icon_url and test_action are template-derived and resolved per caller, so a subscriber refetches its listing rather than rendering from the event.
  • Live Map — /map in the dashboard: a radial force-directed graph of the org's fleet (users at the centre, agents and subagents orbiting, services on an outer ring) with packets animating along the edges as calls happen. Gated behind OVERSLASH_LIVE_MAP (D58), on in dev and prod (D116), reported to the dashboard as live_map on GET /v1/version, which is what gates the nav item. Fed by a fourth stream topic, activity, carrying action.called / action.completed — the first events on the action call path, emitted from the call_action metrics wrapper so the outcome taxonomy (called | denied | rejected | failed | upstream_error) is stated once. Audience is chain(actor), so an admin sees the whole org and a member sees their own chain, with no second ACL. Structure comes from GET /v1/identities + GET /v1/services; agent→service edges are activity-derived (there is no bulk permissions endpoint) and expire after five idle minutes. Approvals show as an amber node state off approval.pending/.resolved; resolving from the map is not wired. Each ownership cluster — a user and everything under them — is enclosed in a dashed container drawn on the canvas, named by a DOM chip pinned to its top-left corner; the chip counter-scales so the name stays readable at any zoom. Clicking a chip folds the whole cluster into it, leaving a +N count and a breathing "N active" / "N waiting" readout so a folded fleet still reports itself; dragging a chip moves the cluster as one. Membership is graph.rootOf, which the physics already used to keep a user's agents together, so a container encloses a grouping the layout was producing anyway. A user-level service instance joins its owner's container and sits on that user's spoke, held there by a spring to the owner rather than by the ring: instance names are unique per (org, owner), so nodes are keyed by owner + name and three users' gcal are three balls in three boxes. Org-level instances keep the shared outer ring, in no container — one of those is called from several clusters — and draw dashed, with their idle edges faded so the ring does not read as structure. The stream carries only a service name, so Graph.serviceIdFor picks the ball the way the gateway picks the instance: the actor's own user-level one shadows the org one. Identity labels come from #lib/identityDisplay, so a container chip names its user the way the users list does — domain-stripped email, with the full handle on the tooltip. Folded members stay in the simulation, so nothing else on the map moves when a cluster opens or closes. User nodes draw their IdP avatar, service nodes draw their catalog mark (D63) off the icon_url already on GET /v1/services, and agent nodes draw their MCP client mark plus hash stripe (D70); a service whose template resolves none keeps its monogram. Ball images are inert to pointer input (draggable="false", pointer-events: none) — a node is a drag handle, and without that the browser's native image drag wins the gesture and you drag a ghost of the picture instead of the node. The fleet snapshot is kept live by the services topic (D87): all four service.* events and stream.resync refetch listIdentities() + listServices() on a trailing-edge 15s cooldown, so an instance created while the map is open joins its owner's container with its catalog icon rather than appearing on the shared ring as a bare-name placeholder on its first call. Traffic naming a service the snapshot does not list asks for the same refetch once per service id, as a fallback for an API that does not emit the topic.
  • Agent inbox — the overslash meta-service carries get_events (poll for anything waiting on this identity: approval_needed / ready_to_call / result_unread) and get_result (fetch one execution's outcome via GET /v1/approvals/{id}/execution). Closes the hole where an action auto-executed under auto_call_on_approve was invisible to its requester: /call answers 409 once the execution is terminal, and list_pending used to drop terminal rows. list_pending now also retains terminal-but-unread executions. Read-tracking is server-side (executions.result_viewed_at), so get_result doubles as the acknowledgement and the event self-clears. Same feed on the CLI: overslash inbox and overslash get-result <approval_id>; classification is shared via services::inbox. GET /mcp remains a 405 — MCP itself has no push channel — but the transport-agnostic one now exists: GET /v1/events/stream (SSE, D45).
    • overslash_auth → dispatched per-action: whoami and service_status. The self-management actions (list_secrets/request_secret/create_subagent/create_service_from_template) have been intentionally pulled from the MCP surface — they live in the dashboard until docs/design/agent-self-management.md lands.
  • overslash web + embed-dashboard Cargo feature embeds dashboard/build/ (built with @sveltejs/adapter-static) via rust-embed. Cloud Vercel build path unchanged.
  • Infra image still tagged overslash-api:* to keep Artifact Registry stable; only the in-container entrypoint changed (overslash serve).
  • MCP puppet client (crates/overslash-mcp-puppet) — generic Streamable-HTTP MCP client with full SSE handling, used as the puppet for e2e flows. Ships as: a Rust library, a REST server (binary overslash-mcp-puppet) that the harness boots alongside the API, and a thin TS wrapper in dashboard/tests/scenarios/mcp-puppet.mjs. Per-call elicitation answers (FIFO queue) plus a suspend/resume API for inspect-then-answer tests. Replaces the slim fetch-based driver under dashboard/tests/e2e/fixtures/. Verified by 9 unit + integration tests against an in-test mock server, plus three Playwright specs (mcp-capabilities, mcp-approval-bubbling, mcp-elicitation).

What's Deployed

  • Marketing site: www.overslash.com — landing page with Terms and Privacy Policy.
  • Cloud infra: dev environment provisioned via infra/env/dev.tfvars (Cloud Run + Cloud SQL + GCLB wildcard cert on *.api.overslash.com + monitoring module + Stripe webhook endpoint). Prod tfvars exists; GA cutover gated on Launch Blockers.
  • Local dev: Docker Compose (Postgres on port 55432); worktree isolation via make local.

Infrastructure

  • Repository: overfolder/overslash (private, will be open-sourced)
  • Default branch: master
  • CI: GitHub Actions with coverage reporting, real OAuth provider tests
  • Vulnerability disclosure: SECURITY.md (contact security@overslash.com, response and fix SLAs, scope, safe harbor) and an RFC 9116 security.txt served by the dashboard at /.well-known/security.txt; scripts/check-security-txt.sh fails the daily Dependency audit a month before its Expires.
  • Dependency scanning: cargo-deny + npm audit + OSV over every lockfile (.github/workflows/deps-audit.yml), gating PRs that change dependencies and running daily on dev + master; scheduled failures file a needs-triage issue. Policy and exception register: docs/compliance/casa/dependency-vulnerability-policy.md (D101)
  • PR flow: feature branches → dev → master
  • IaC: OpenTofu under /infra — deploys to GCP Cloud Run with Cloud SQL, Artifact Registry, Secret Manager, Cloud Build, and optional Memorystore/DNS
  • Mailbox Gateway: shared overfwd deployment on Cloud Run (infra/modules/cloud-run-overfwd/, enable_overfwd) serving mailbox.overslash.com / mailbox.dev.overslash.com — the servers[0] of services/email.yaml. Digest-pinned third-party image via the Artifact Registry Docker Hub mirror; requires a bearer key that the API supplies for every org through the platform-credential rung, so no org stores it (D39). Runbook: docs/runbooks/mailbox-gateway.md
  • Docker: Multi-stage Dockerfile (Rust build → Debian slim runtime), docker-compose.prod.yml for local prod-like testing
  • Environments: dev (overslash-dev) and prod (overslash) via infra/env/*.tfvars
  • Deployment: make tofu-plan ENV=dev && make tofu-apply ENV=dev