Settled architectural decisions. Don't re-litigate without new information.
Date: 2026-03 Decision: Overslash is a standalone REST API service, not embedded in Overfolder's agent-runner. Rationale: Auth, secrets, approvals, and action execution are general-purpose. Any agent platform should be able to use Overslash. Decoupling also simplifies agent-runner.
Date: 2026-03
Decision: Overslash owns OAuth flows natively instead of using Nango.
Rationale: Nango adds a dependency and limits control over the token lifecycle. Overslash needs tight integration between OAuth tokens, permission rules, and approval workflows. See docs/design/nango-integration.md for the evaluation that led to this decision.
Date: 2026-03 Decision: Use Rust/Axum, matching the Overfolder stack. Rationale: Shared expertise, proven stack, consistent tooling. AES-256-GCM for secrets at rest.
Date: 2026-03 Decision: Use Valkey (not Redis) for caching and pub/sub. Rationale: Valkey is the open-source fork of Redis, maintained by the Linux Foundation. License-compatible, drop-in replacement, actively developed. No reason to use Redis's restrictive SSPL license.
Date: 2026-03
Decision: Default to Cloud SQL Auth Proxy mode instead of VPC private networking.
Rationale: VPC connector costs ~$7/month even idle. Auth Proxy is free, secure (IAM-authenticated), and sufficient for pre-GA. VPC mode is available via use_private_vpc = true for production hardening later.
Date: 2026-03
Decision: Prefer Podman / podman-compose over Docker where available.
Rationale: Rootless by default, daemonless, OCI-compliant. Docker is supported as fallback. Makefile auto-detects podman-compose first.
Date: 2026-03
Decision: inherit_permissions is a live pointer, not a copy. Child dynamically has parent's current + future rules.
Rationale: Static copies create drift. Live pointers mean granting a user a new permission automatically flows to their agents. See SPEC.md for full design.
Date: 2026-04 Decision: Rate limits use two counters per request: a User-level bucket (shared by all agents under that user) and optional per-identity caps. Not per-agent buckets alone. Rationale: Per-agent-only limits are easily circumvented by spawning sub-agents. The User bucket ensures a hard ceiling regardless of agent topology. Identity caps are a convenience for isolating misconfigured agents from consuming the entire User budget. See SPEC.md §13.
Date: 2026-04
Decision: PRs target dev and merge through GitHub's merge queue (squash, ALLGREEN, required check ci-ok, strict-up-to-date off — the queue handles rebasing). dev flows to master via merge commits only (master ruleset disallows squash/rebase) so feature history is preserved on master. Repo was made public to unlock merge queue without an Enterprise upgrade. The Stop hook arms gh pr merge --auto --squash once its three gates pass, but only when the PR's base branch is dev — never on PRs targeting master.
Rationale: Keeping branches up-to-date with base was a recurring source of agent churn. The merge queue serializes PRs and rebases them in-place, eliminating that responsibility. Squash on dev keeps feature PRs as single commits; merge-commits on master retain the full feature history at release-cut time. See .claude/hooks/pr-mergeability-gate.sh and rulesets dev (id 14770759) / master (id 14707284).
Date: 2026-04
Decision: MCP clients connect via POST /mcp on the API, gated by Authorization: Bearer. Two single-credential modes: user JWT (aud=mcp, minted via /oauth/authorize → /oauth/token on the same Axum process) or static osk_… agent API key. The dual-credential model and stdio-only transport are retired. overslash mcp becomes a thin stdio↔HTTP compat shim for editors whose MCP transport is stdio-only; overslash mcp login runs the standard OAuth Authorization Code + PKCE flow and writes ~/.config/overslash/mcp.json.
Rationale: OAuth 2.1 is the standard auth flow in the MCP spec, and Streamable HTTP is the reference transport for remote MCP. Hosting the Authorization Server (/.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource, /oauth/register, /oauth/authorize, /oauth/token, /oauth/revoke) next to the API means DCR, consent, refresh, and revocation share infra and reuse the existing IdP login flow. Editors speaking stdio get the compat shim so Overslash doesn't break their setup. Implementation landed in PR #121 (single binary) and PR #123 (HTTP transport + AS). Full design at docs/design/mcp-oauth-transport.md.
Date: 2026-04
Decision: GET /v1/search (§10) ranks candidates with a hybrid of keyword + Jaro-Winkler fuzzy and pgvector cosine similarity, where the embeddings come from locally hosted BAAI/bge-small-en-v1.5 (384-dim) via the fastembed crate. Dev, CI, and the shipped compose images run pgvector/pgvector:pg16; vanilla Postgres is supported — both the extension migration and the table migration are wrapped in DO $$ blocks that probe pg_available_extensions and no-op cleanly. A boot-time preflight (SELECT … FROM pg_extension) plus the env kill-switch OVERSLASH_EMBEDDINGS=off force-disable embeddings at runtime; search then falls back to keyword + fuzzy transparently.
Rationale: The service/action catalog is tiny (~9 global templates × ~20 actions plus DB-tier templates) — an external embedding API would add a per-query cost, a new secret, and network latency for a corpus that fits trivially in CPU-embedded memory. The local model is a one-time ~130 MB download cached under OVERSLASH_EMBED_CACHE_DIR; ONNX runtime adds ~40 MB to the binary, which is acceptable for a single-binary server distribution (the embeddings Cargo feature lets library consumers opt out). Keyword + fuzzy alone handles exact matches and typos well but misses paraphrased intent — the embedding signal covers that gap, and the hybrid weighting (0.4 keyword + 0.6 embedding) keeps exact service / action names dominant when they match literally. The pgvector no-op path means a self-hosted deploy on vanilla Postgres still boots and serves search, just without the embedding signal.
Date: 2026-04 (amended 2026-05)
Decision: Overslash treats each IdP as its own trust domain. An IdP can only admit members into resources it controls: Overslash-level IdPs admit into personal orgs and into corp orgs the user themselves created (the creator becomes a regular admin of the new org); per-org IdPs admit into that org only. Users are keyed at auth time by (provider, subject), never by email. There is no cross-IdP account linking — a human who uses Google for their personal Overslash account and Okta for Acme simply has two distinct users rows.
Rationale: If email alone could attach a login to an existing membership, a user who registers a Google account claiming amartcan@acme.org would inherit whatever Acme provisioned for its real employee via Okta. By restricting each IdP to its own trust domain, an external IdP (Google) cannot vouch its way into resources controlled by an internal one (Acme's Okta).
2026-05 amendment — opt-in invite-gated admission: a corp org may opt in (via orgs.allow_overslash_managed_signin, default true for new orgs) to invite-gated membership. Two things change when the flag is on:
- Authentication via Overslash's shared OAuth apps (
GOOGLE_AUTH_*,GITHUB_AUTH_*, future env-var providers) becomes available — until now D12 forbade env-var creds on corp subdomains. - Sign-in is decoupled from membership: the IdP proves who you are, and a second gate — including authentications through a dedicated
org_idp_configsrow — decides who belongs. The per-providerallowed_email_domainswhitelist is bypassed when the flag is on.
2026-07 amendment (migration 092) — invite vs domain is itself a toggle: the second gate is configurable via orgs.require_invite_admission (default true, preserving the 2026-05 behavior above):
require_invite_admission = true— invite-only: every new member must match a pendingorg_invites(email, role)entry.require_invite_admission = false— domain admission: any verified email whose domain is on the org-wideorgs.managed_signin_allowed_domainslist self-provisions asmember. An empty allowlist is treated as misconfigured (rejectdomain_admission_not_configured), never as open admission.
The trust boundary is either "the admin's curated invite list" or "the admin's curated domain allowlist" — both admin-controlled. Domain admission trusts the verified-email domain (split on @, case-insensitive); it does not verify Google's hd/Workspace claim (see TECH_DEBT.md). The email-spoofing concern the original D12 raised does not apply: there is no second-source IdP to phish; the managed IdP authenticates the email holder and the admin chose which emails/domains matter. Existing orgs keep both flags at their safe defaults (allow_overslash_managed_signin per provisioning, require_invite_admission = true) until an admin opts in. Full design at docs/design/multi_org_auth.md.
2026-08 amendment — one resolver for provider availability: the "which IdPs can this org sign in with" rule (enabled org_idp_configs rows ∪ managed providers when the flag is on, dedicated wins on collision) was open-coded in every handler that needed it, and they drifted. /oauth/authorize never learned the 2026-05 amendment at all and answered 503 login_required for managed-signin-only orgs; /auth/providers and credential resolution disagreed about disabled rows. crates/overslash-api/src/services/org_signin.rs now owns the rule, and /oauth/authorize, /auth/providers, /v1/org-idp-configs and resolve_auth_credentials all read it from there. Admission (invites / domain allowlist) stays separate in routes::auth::provisioning — availability is not membership. Relatedly, anything that starts a login on a corp subdomain must redirect to the org's app host: the 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 — has its cookies rejected by the browser.
Date: 2026-05
Decision: auto_call_on_approve lives on the agent identity itself (identities.auto_call_on_approve, default true) and applies uniformly to MCP, REST, and white-label agents — there is no separate per-MCP-binding column anymore. Org admins can flip orgs.default_deferred_execution to seed new agents with auto-call OFF (existing agents keep their value). When auto-call is ON, the approval.executed webhook payload includes the full execution result (only when triggered_by="auto" and the call succeeded); manual /call paths omit it because the caller already received the result inline. The toggle also governs cascade-resolved approvals (resolved_by='cascade'): each cascaded approval is auto-called per its own requesting identity's setting; because cascade executions are created with remember=false, a cascade-triggered replay never writes rules or re-cascades.
Rationale: Pre-migration the toggle was on mcp_client_agent_bindings, so REST API agents and white-label embeddings (Overfolder, etc.) silently fell through to manual-only POST /v1/approvals/{id}/call. White-label platforms are explicitly first-class consumers per SPEC.md §1, and the bound-to-MCP gate forced every white-label integration to do an extra round-trip on every approval. Moving the toggle to the identity makes the policy uniform across surfaces; the org-level default lets a tenant flip the policy without touching individual agents; and including the result in the webhook lets a white-label UI render the outcome from a single delivery instead of a follow-up GET /v1/approvals/{id}/execution. Manual-call paths are unchanged because the caller already has the response.
Date: 2026-05
Decision: POST /v1/actions/call no longer accepts a top-level connection: <uuid> field. Callers that previously used it (raw URL + stored OAuth connection) must instead use SPEC §8 "Service + HTTP verb" — naming a service instance plus method + path (or url). The instance binding provides auth; the template's hosts[] bounds where the bearer can land. The CallRequest parser uses deny_unknown_fields, so stale callers get a parse-time 400 naming the removed field.
Rationale: The connection-based shape was an implementation deviation from SPEC §8 and carried a host-binding gap — host(req.url) was never validated against the connection's provider_key, so an agent with a managed OAuth connection could direct the bearer at any URL and exfiltrate the token. Implementing SPEC §8 "Service + HTTP verb" subsumes the legitimate use case (free-form authed calls) while inheriting the host bound from svc.hosts. Removing the deviated shape is a straight code-and-surface reduction; nothing in-tree depended on it that wasn't trivially rewritable to the verb shape.
Date: 2026-05
Decision: Mode A (raw HTTP) is implemented as the synthetic http service: a global template (hosts: [], auth: [], runtime: Http) shipped in the registry and a system-managed org-level service instance (is_system = true, no owner, no credentials) created for every org at bootstrap. Callers send service: "http" with method + url (the no-service legacy shape is rejected with a 400 carrying a migration hint). The groups.allow_raw_http boolean was dropped in migration 063 — group access to raw HTTP flows through the standard group_grants mechanism on the http instance, with the same access-level → verb-risk mapping as any other service. Permission keys derive identically to the legacy form (http:{METHOD}:{host}{path}) so existing rules continue to match.
Rationale: Pre-migration, raw HTTP carried two parallel code paths (a separate Mode A branch in actions.rs::resolve_request and resolve_action_metadata, a service_name == "http" special case in check_group_ceiling, and a dedicated PermissionKey::from_http builder) plus a special-purpose allow_raw_http boolean. Treating http as just another service collapses all four into the standard verb-shape path: actions.rs loses ~60 lines of branch duplication, permissions.rs loses the http special case + from_http, the column is gone, the dashboard's standalone "Allow raw HTTP" toggle disappears (raw HTTP is granted via the same UI as github or slack), and access-level granularity gains read/write/admin instead of a binary flag. Owner experience is unchanged — Everyone+Admins keep an admin grant on the http instance from the migration backfill, mirroring the prior allow_raw_http = true default. Org admins can now downgrade or revoke raw HTTP per-group like any other service.
Date: 2026-05
Decision: POST /v1/actions/call accepts an optional return_url body field. When a call reactively mints an OAuth flow — reauth_required (refresh-token failed / no refresh token), missing_scopes (incremental scope upgrade), or needs_authentication (no connection yet) — that return_url is stamped onto the minted flow row, so the /v1/oauth/callback handler 303-redirects the user back to the partner once consent completes, identical to the first-connect path. The hint is format-validated once at the request boundary (parse_return_url → 400 on malformed input); the host is re-checked against OVERSLASH_CONNECTION_RETURN_URL_HOSTS at callback time, and an off-list host silently falls back to the historical JSON response.
Rationale: First-time connects already pass connect_return_url → flow row → callback 303. But reactive reauth/upgrade flows are minted server-side during a failed action call, where the partner had no first-class place to supply a return URL, so they hardcoded return_url: None and the user landed on raw callback JSON. A body-field hint on the action call (mirroring return_url on the connect endpoint) closes the gap with no new transport surface and reuses the existing boundary validator and callback-side allow-list gate unchanged. The org-config alternative (a per-org configured callback URL) was rejected as heavier schema for no extra safety — the allow-list already bounds where the callback will redirect.
Date: 2026-06
Decision: The shipped services/github.yaml template targets GitHub App user-to-server tokens: the OAuth flow declares no scopes (GitHub Apps ignore the scope parameter — access is the intersection of the app's fine-grained permissions and its installations), and the org's configured GitHub client credentials (OAUTH_GITHUB_CLIENT_ID/SECRET or BYOC) must belong to a GitHub App. The previous classic-OAuth-App template is retained verbatim as services/github_legacy_oauth.yaml (key github_legacy_oauth, title "GitHub (Legacy OAuth)") for orgs whose configured client is still an OAuth App; it is pre-annotated x-overslash-hidden: true in its info block so it drops out of the catalog once hidden-template support lands. Both templates keep provider: github — same provider row, credentials cascade, and connections pool, so existing connections and permission rules (github:<action>:{repo}) work unchanged. Installation (server-to-server) tokens are out of scope — they need RS256 JWT signing with an app private key, which doesn't exist in the gateway yet.
Rationale: GitHub recommends GitHub Apps over OAuth Apps: fine-grained per-repo permissions instead of broad repo scope, and short-lived (~8h) tokens with refresh. The user-to-server flow is endpoint-compatible with the OAuth App flow (same authorize/token URLs, same code-exchange and refresh grants), so the migration is template-only: the existing refresh machinery handles expiring tokens (oauth_providers.supports_refresh defaults true), the scope gate passes because github actions declare no required_scopes, and the default_identity_scopes sent on the authorize URL are harmlessly ignored by GitHub Apps. Keeping the legacy template as a separate hidden key (instead of mutating github in place per-org) gives OAuth-App orgs a zero-breakage path while new setups land on the recommended model.
D18: Audit response-body capture is org-opt-in, inline, and truncated; transport failures always audit
Date: 2026-06
Decision: Upstream response bodies can be persisted on action.executed audit rows under detail.response ({body, truncated, content_type}), governed by a new org setting orgs.audit_response_body_mode (off default / errors_only / all, managed via GET/PATCH /v1/orgs/{id}/audit-settings). "Error" reuses the normalized detail.is_error semantics (upstream HTTP ≥ 400, MCP in-band error). Bodies are stored inline in the existing detail JSONB as strings — truncated at a char boundary to AUDIT_RESPONSE_BODY_MAX_BYTES (64 KB default) and NUL-sanitized (Postgres rejects in jsonb and log_audit swallows errors, so an unsanitized body would silently drop the row). Streamed executions record response: {skipped: "streamed"} instead — their bodies never pass through a buffer. Platform-runtime calls are excluded (in-process, no upstream). Independently of the setting, transport-level failures (DNS/connect/timeout, response-too-large, MCP transport/JSON-RPC errors) now always write an action.executed row with is_error: true and a fixed-string detail.error {kind, message} — never the raw reqwest/MCP error text, whose Display can carry the resolved URL with injected secrets.
Rationale: The response body is the most useful artifact when debugging a failed agent action, but storing every body is a privacy and storage liability — so capture is admin-gated, defaults off, and offers an errors-only middle mode (error payloads are small and the debugging gold; success bodies are the volume). Inline JSONB avoids a migration and a lazy-fetch endpoint at 64 KB scale; a string (not parsed JSON) keeps one predictable shape since truncated bodies rarely parse. The transport-failure rows fix an audit blind spot: before this, a DNS failure or timeout was visible in metrics (#368) but wrote no audit row at all — is_error only covered responses that arrived.
Date: 2026-06
Decision: Release tagging and changelog generation are automated with release-please (manifest mode, single root component, release-type: simple). After a dev → master promotion PR merges, release-please.yml opens a chore(release): vX.Y.Z PR against master computed from the conventional commits since the last release tag (bump-minor-pre-major: true so a feat!: bumps minor, not 1.0.0); merging it creates the tag + GitHub release, and the tag push triggers the existing release.yml binary build unchanged. The workflow authenticates with the RELEASE_PLEASE_TOKEN fine-grained PAT (contents + pull-requests write) because GITHUB_TOKEN-created PRs/tags never trigger other workflows. Crate versions in Cargo.toml stay untouched (bumping them would invalidate Cargo.lock and break the --locked release build); instead the release workflow injects OVERSLASH_VERSION at compile time and the CLI reports it via common::version() (option_env! with CARGO_PKG_VERSION fallback for local builds). Two follow-ons (2026-07): the release commit is merged back into dev automatically by sync-dev.yml on the tag push — a merge, never a rebase, since dev forbids non-fast-forward pushes, and never a sync PR, since dev's squash-only rule would leave the release commit outside dev's history and the drift would recur; and non-release builds no longer report the frozen 0.1.0 — overslash-core's build.rs emits OVERSLASH_VERSION from .release-please-manifest.json with a -dev suffix (0.5.0-dev) when the release workflow did not set it, which is what makes the sync-back load-bearing rather than tidiness.
Rationale: v0.1.0–v0.2.0 were manual tag pushes: easy to forget, no changelog, and the binaries reported the stale crate version (0.1.0) because nothing ever bumped Cargo.toml. release-please leans on the conventional-commit discipline dev's squash-merge policy already enforces, so the version bump and changelog come for free. The compile-time version override was chosen over having release-please bump the ten workspace Cargo.tomls because the crates are internal and unpublished (their versions are cosmetic) while a Cargo.toml-only bump without a regenerated Cargo.lock hard-fails cargo build --locked — wiring lockfile regeneration into the release PR would add a custom step for zero benefit. Runbook: docs/runbooks/release.md.
Date: 2026-06
Update: The integration-managed refresh mode and the connection.refresh_required webhook described below are superseded by D21 — byoc_credential_id is now required on import (no null/no-client mode) and white-label auth-recovery is driven by the per-org headless flag instead. The orchestrated-vs-vault and "overslash issues no redirect_uri" parts stand.
Decision: White-label partners (e.g., Overfolder) that own their OAuth no longer route the dance through overslash. They run authorize + code-exchange themselves and POST /v1/connections/import the resulting {access_token, refresh_token?, expires_at?, scopes?, account_email?, byoc_credential_id?}; overslash stores the connection (identical row to an orchestrated callback), refreshes it, and injects it at execution — issuing no redirect_uri. Refresh is per-connection and fixed at import: self-refresh when the import references a BYOC client via byoc_credential_id (overslash runs the grant_type=refresh_token call autonomously, hard-pinned to that client — no redirect_uri needed), or integration-managed (connections.integration_managed = true) when byoc_credential_id is null, where overslash injects until expiry then surfaces reauth_required — marked integration-managed with no overslash reconnect link — plus a connection.refresh_required webhook, for the partner to refresh and re-import. Overslash refreshes only when it holds the connection's client: an imported connection with a null byoc_credential_id does not fall back to the env/org OAUTH_*_CLIENT cascade (a refresh token is valid only against the issuing client). Secretless import (no client) is explicitly supported — it also covers opaque bearer tokens/PATs — but BYOC self-refresh is the recommended path for unattended agent execution. This reverts the per-request redirect_uri override + oauth_callback_allowed_hosts allow-list (#388/#392) and the per-org oauth_redirect_url + use_org_redirect switch (#398), and removes POST /v1/oauth/exchange, the include_raw/raw-authorize-URL surface, and oauth_connection_flows.redirect_uri (migration 079). Orchestrated OAuth (default /v1/oauth/callback) stays for normal orgs and the dashboard's own connect UI. Full design: docs/design/white-label-token-vault.md.
Rationale: The redirect_uri belongs to a provider's OAuth client, and clients are per-provider — so any model that keeps overslash issuing the redirect inherits a per-provider configuration problem (a single per-org URL forces all providers to share one callback; a per-request override needs an allow-list and per-call URL plumbing). A partner that already owns its OAuth gains nothing from overslash re-doing the dance. Making overslash a pure token vault deletes the entire redirect/exchange/allow-list surface, is inherently multi-provider, and matches overslash's identity (secret management + authenticated execution); the permission-chain and approval value-add still apply at execution time regardless of where the token came from. The key enabler is that the refresh-token grant needs only client_id + client_secret + refresh_token — never a redirect_uri — so overslash can keep tokens fresh without ever touching the authorization-code redirect.
Date: 2026-06
Decision: The connections.integration_managed flag (D20) is removed (migration 085_drop_connection_integration_managed) because it conflated two unrelated axes: who refreshes a connection and who runs the user-facing auth flow. They are split cleanly: (a) refreshability is structural — a connection with a pinned byoc_credential_id self-refreshes via that client, one without refreshes via the orchestrated org/env cascade; no stored boolean. (b) flow-ownership is a per-org capability, orgs.headless (migration 084_orgs_headless, admin/provisioning-only via GET/PATCH /v1/orgs/{id}/headless, no dashboard toggle). POST /v1/connections/import now requires byoc_credential_id (null → 400), so the no-client "integration-managed" mode (inject-until-expiry, never refresh) is gone and every imported connection self-refreshes. For a headless org, the three auth-recovery envelopes (reauth_required, needs_authentication, missing_scopes) are URL-less: they omit auth_url/short (and upgrade_url for missing_scopes), carry a headless: true discriminator (present only on these URL-less variants) plus provider/required_scopes/account_email, and mint no oauth_connection_flows row; the integration re-runs its own dance and re-imports (idempotent on identity+provider+account_email). upgrade_scopes is rejected for headless orgs. The connection.refresh_required webhook is dropped — the signal is inline on the failing call. The gated /connect-authorize flow is unchanged for non-headless dashboard customers.
Rationale: A white-label org's end users have no Overslash dashboard session, so handing them a gated /connect-authorize link is a white-label violation — yet integration_managed keyed that behavior off how the connection refreshes, which is orthogonal and (post-D20) the wrong question. Nobody was relying on the no-client import mode yet, so requiring BYOC removes a weak path (a connection that lived only as long as the imported token) and lets the single remaining special case — URL suppression — be driven by the org capability that actually models the white-label boundary. A per-org flag is the pragmatic stand-in for a full flow_owner axis without a connection-level migration. Breaking for the one white-label consumer (Overfolder), which already pins a BYOC on import and consumes the envelope — coordinated cross-repo change. Full design: docs/design/white-label-token-vault.md.
Date: 2026-06
Decision: Connections are not user-scoped — connections.identity_id can point at a user or an agent, and storage/ownership semantics are unchanged. What changes is the action-execution auth resolver: it now looks connections up at the owner identity instead of the calling identity. resolve_service_auth, check_required_scopes, and the auto-resolve fall-through of resolve_instance_auth derive the owner via group_ceiling::ceiling_user_id_from_identity (user→self, agent/sub_agent→owner_id) and build their UserScope from it before calling find_my_connection_by_provider (note: UserScope is really an identity scope — its user_id field holds any identity_id, so this is a read-path change only). The three reactive auth-recovery mints (reauth_required, needs_authentication, missing_scopes) and the OAuth client-credential resolution are keyed on the same owner identity, so a freshly minted connection lands on the owner and one reauth heals every agent under it. Service-instance binding and permission/group-ceiling checks remain on the calling agent identity (group ceiling unchanged). Explicit instance→connection bindings still resolve org-scoped via scope.get_connection and are untouched. This aligns the connection read path with the write path, where on_behalf_of import already binds to the owner. Owner-only — no caller fallback: a legacy agent-bound connection surfaces one needs_authentication, after which the reconnect lands on the owner; the sibling import/migration re-homes the rest.
Rationale: The resolver previously built UserScope::new(org_id, calling_identity_id), so a child agent never saw the owner user's connection. With Overfolder binding connections to the user identity (connect/reauth flow + on_behalf_of import), an agent holding its own stale connection got reauth_required for a row the user's reauth flow could never heal — an infinite reauth loop (dev trace d57fe333: agent connection f46cabbe broken, user connection 85844f1a healthy). Resolving at the owner makes "one credential per (user, provider)" the effective model at execution time without changing connection storage, while keeping the per-agent permission ceiling intact. Owner-only (vs. caller-first or nearest-ancestor) is required precisely because the bug case has a broken connection closer in the chain than the healthy one — any strategy that prefers the nearest connection would pick the broken agent row.
Date: 2026-06
Decision: The connection write path now matches the D22 read path: kernel_create_connection and kernel_import_connection bind the resulting connection to the owner identity (ceiling root via group_ceiling::resolve_ceiling_user_id, user→self / agent→owner_id) by default — instead of falling back to the calling identity when on_behalf_of is omitted. on_behalf_of, when supplied, is still validated (validate_on_behalf_of enforces target == caller's owner) but the binding is the owner either way; audit attribution stays on the caller (kernel_create_connection_for_identity takes caller_identity_id separately; import logs identity_id: Some(caller_identity_id)). Migration 086_connection_owner_identity re-homes existing agent/sub_agent-bound rows: owner-wins delete for a same-(provider, account_email) owner row, most-recent-wins collapse among remaining agent rows, re-point to the owner, then re-establish one is_default per (identity, provider) (the partial unique index forces demote-before-re-point). Down is a documented no-op (the original agent identity isn't retained).
Rationale: Without this, every implicit (no-on_behalf_of) agent connect/import accreted a connection on the agent that the D22 read path could never see — the exact source of the dev mismatch (the "main" agent 52d10791 held 4 leftover google connections while the real one lived on user A M 1d6a5835). Binding writes to the owner makes storage agree with resolution so connections stop accreting on agents, and the migration heals the legacy rows that D22 noted it would "surface one needs_authentication" for. Reusing resolve_ceiling_user_id/validate_on_behalf_of keeps the change to a few lines per kernel with no new identity-walking logic.
Date: 2026-07
Decision: services/slack.yaml wraps Slack's official first-party MCP server (https://mcp.slack.com/mcp, GA Feb 2026) as an x-overslash-runtime: mcp template on the auth.kind: oauth provider-connection mechanism shipped for MCP servers in #418 (alongside HubSpot). Overslash is the MCP client: it exposes Slack's tools through /v1/actions/call, decorates only the tools that benefit from gateway features (the write send_message gets disclose + scope_param: channel; the ID-scoped reads read_channel_history/get_user get scope_param; plain reads stay bare), and resolves the caller's owner Slack connection (D22) into the outbound bearer. Two gaps in the #418 mechanism are closed so both Slack and HubSpot behave correctly: platform_services::template_oauth_provider now also returns an MCP auth.kind: oauth provider (so auto-connect orchestration, pinned-connection validation in kernel_create_service, and credentials-status surfacing treat MCP-oauth like HTTP OAuth), and McpDetail exposes the provider so the dashboard can render a "connect " affordance for oauth MCP services (service detail + create pages reuse the existing OAuth connect surface).
Rationale: Slack's MCP server is broader and LLM-native (search, canvases, drafts, reactions, files) and is maintained by Slack — wrapping it tracks Slack's capabilities for free while Overslash still owns permissions/approvals/audit, instead of hand-wrapping a handful of Web API methods that would drift. OAuth (not a static bot token) is required because the server is an OAuth-protected resource; the DCR-based nested-upstream-OAuth path can't be used because Slack's MCP auth server (like HubSpot's) doesn't support Dynamic Client Registration — the oauth provider-connection kind sidesteps DCR by reusing a pre-configured provider client. The template_oauth_provider/McpDetail fixes are the difference between an oauth MCP service that self-heals and connects through the dashboard versus one that silently 400s on a pinned connection and never prompts to connect. Caveat: the seeded slack provider targets the v2 bot-token endpoints; Slack's MCP prefers a user token (oauth.v2.user.access), so a production deployment configures the provider/scopes accordingly — the injection mechanism is identical either way.
Date: 2026-07
Decision: Two distinct trial mechanisms, unified in the dashboard as one "trial" banner. (a) Instance-admin managed trials extend the orgs.plan CHECK with a 'trial' tier and add orgs.trial_ends_at TIMESTAMPTZ (migration 093_org_trial, mirroring 052). An org is "on trial" while plan='trial' and trial_ends_at is in the future, "expired" once it passes — the lifecycle is derived, no separate status column and no background sweep. Instance admins (session-only InstanceAdminAuth) start (POST /v1/orgs/{id}/trial, defaulting to TRIAL_DEFAULT_DURATION_DAYS, default 30), bump (PATCH /v1/orgs/{id}/trial, extends from the later of current-end/now so an expired trial still gains a fresh window), and opt out (PATCH /v1/orgs/{id}/plan → standard|free_unlimited, which clears trial_ends_at); each invalidates FreeUnlimitedCache (generalized to cache (plan, trial_ends_at) and answer both is_free_unlimited and trial_status, 30s TTL). Enforcement is banner-only: an expired trial keeps serving API calls and stays under normal rate limits — it is NOT free_unlimited, so it gets no bypass, and there is NO hard gate in the rate-limit middleware. Expiry only changes what the dashboard shows (an escalating banner fed by /auth/me/identity.trial, which reaches every member, plus the admin-only synthetic /v1/orgs/{id}/subscription branch reporting status: trialing | trial_expired). (b) Self-serve trials ("Not sure. Trial for free for a month" on the org-create page) are Stripe-native: the checkout sets subscription_data[trial_period_days], so a card is collected but the first charge is deferred by the trial window (Stripe emits status='trialing'). These stay plan='standard' with a trialing subscription and do not touch the trial tier. free_unlimited (e.g. Reveni) is exempt from both — never 'trial', never billed.
Rationale: A plan tier plus a nullable timestamp is the smallest change that reuses the existing free_unlimited plumbing (CHECK constraint, FreeUnlimitedCache, synthetic-subscription branch) and keeps "add a tier" a one-migration operation. Deriving the lifecycle from trial_ends_at avoids a background job and a second status column. Banner-only is a deliberate product choice (confirmed with the operator): a hard gate that locks an agent gateway out mid-workflow is hostile for an evaluation period, and the middleware stays simpler for it; the tier + timestamp scaffolding leave a cached hard-gate as a small future change if ever needed. Routing self-serve trials through Stripe with a card on file closes the billing-bypass hole that a free self-serve org would open (org creation is otherwise blocked under cloud_billing precisely to force everyone through Stripe) while reusing the existing checkout, webhook status-mirror, and the dashboard's pre-existing trialing handling almost entirely.
Date: 2026-07
Decision: On a corp subdomain (RequestOrgContext::Org { org_id }), /oauth/authorize enrolls the agent into org_id unconditionally — the session org, any pre-existing binding, and the client's registration are all subordinate to the subdomain (design: mcp-enrollment-org-scoping.md, builds on D12). Three code changes in crates/overslash-api/src/routes/oauth.rs make the subdomain the boundary: (1) the parked request's org_id is derived from ctx, not session_claims.org; (2) a warm session whose org ≠ the subdomain org is re-authed through the org's IdP (idp_bounce, next= preserved) rather than silently enrolling into the session org; (3) the fast-path (user, client_id) → agent lookup is org-scoped. This required migrating mcp_client_agent_bindings from UNIQUE (user_identity_id, client_id) to (user_identity_id, client_id, org_id) (migration 095_mcp_enrollment_org_scoping). Hardening: DCR clients are org-stamped — oauth_mcp_clients.org_id (nullable) is set from ctx at POST /oauth/register (subdomain → that org; root → NULL = multi-org); a corp subdomain's authorize accepts a client whose org_id is NULL or equals ctx.org and rejects one stamped for a different org (cross-subdomain replay protection), and the admin Org Settings → MCP Clients list/revoke is scoped to the admin's own org. Root apex is unchanged and stays the multi-org hub: no subdomain to honor, so enrollment follows the session org (which may be a corp org the user belongs to), and no re-auth-on-mismatch fires. NULL-org_id clients registered before the migration keep working as "any subdomain."
Rationale: The corporate ask ("point our MDM-managed Claude at acme.api.overslash.com and trust every enrolled agent is an Acme agent") requires the subdomain to be an enforced boundary on where the agent lands, not merely a discovery/cold-login signal. Deriving the org from ctx + re-auth-on-mismatch makes the guarantee structural across every path (discovery, cold login, warm re-auth, fast-path rebind). Org-stamping the client is defense-in-depth that also makes the admin client list correctly per-org; keeping NULL/root clients accepted preserves back-compat and the root multi-org experience without a leak, because items 1+2 force the agent into ctx.org regardless of the client. The subdomain lock is opt-in — you get it by pointing a client at the subdomain — so it never restricts the root hub.
Date: 2026-07-09
Decision: Overloaded MCP tools (e.g. HubSpot's get_crm_objects(objectType, …), manage_crm_objects) may be wrapped in higher-level, single-purpose tools — one per (object-type × operation), like get_contact(id) / search_deals / update_deal_stage — layered alongside the raw tools, which stay as the escape hatch. Two tiers. Tier 1 (works today, no runtime change): alias the action to a raw tool with mcp_tool: and pin the discriminator with a param default: (apply_defaults runs pre-resolve for MCP too). Limitation: the discriminator stays visible/overridable and arg names/shapes must match upstream, since args forward verbatim. Tier 2 (proposed): a new optional per-action field x-overslash-transform — a jq program that rewrites the caller's (defaulted) params into the upstream arguments at the resolve.rs seam (let arguments = serde_json::to_value(&req.params)), reusing the jq engine already behind x-overslash-disclose; absent → verbatim (zero regression). This buys true REST shapes (scalar id → objectIds:[id], locked/hidden objectType, flat map → createRequest envelope, and auto-injection of HubSpot's chatInsights telemetry). Availability (get_user_details) gates each resource's verb set (full CRUD vs read-only vs write-only). Report-building, workflow authoring, and quote/invoice creation are non-goals (no MCP tool backs them). Extension named single-word without underscores to match x-overslash-{risk,disclose,redact,runtime,mcp} — chosen over x-overslash-map / x-overslash-arg_map. See docs/design/hubspot-resty-tools.md and the draft docs/design/hubspot-resty-draft.yaml.
Rationale: The overloaded surface is model-ergonomic but fights Overslash's per-action permission keys, approvals, and disclosure, and cannot express per-type/per-verb availability. Aliasing + defaults already exist, so Tier 1 is free; Tier 2 is one localized seam and one jq field rather than a bespoke templating language, and stays inert for every service that doesn't opt in. Keeping the raw tools avoids losing advanced filterGroups/SQL and un-wrapped object types.
D28: Email integration is a REST facade over the overfwd gateway, not a native mail transport; Overslash uses Inline mode
Date: 2026-07
Decision: Agents send and read/search mail through a separate MIT-licensed OSS project, overfwd (repo overspiral/overfwd), a thin stateless Rust Mailbox Gateway that presents a REST facade (search/get/send) and translates it to IMAP/SMTP (async-imap + lettre + mail-parser/mail-builder). Overslash treats it as an ordinary HTTP service — no Runtime::Imap, no non-HTTP execution path in the core, and JMAP is not used even internally. Scope is send + on-demand read across the standard-IMAP long tail (Migadu, Fastmail, iCloud, Zoho, Proton Bridge, corporate Dovecot/Cyrus…); Gmail stays on its REST service and Microsoft Graph is a separate later track — the gateway explicitly does not cover Gmail/Outlook (they require OAuth XOAUTH2 for IMAP, so the gateway buys nothing there). Own-inbox and real-time inbound are deferred (no inbound-event ingestion subsystem today). Auth has two axes: a gateway api_key (Authorization: Bearer, server-config require_api_key) and a mailbox credential. overfwd offers three credential sources — Inline (creds per request), Portfolio (gateway-stored, referenced by account_id), Session (= an ephemeral Portfolio account) — but Overslash uses Inline only: the user:pass secret is injected as X-Mailbox-Auth: Basic base64(user:pass), host/port are non-secret (interim: prefilled forked templates; a generic per-instance config jsonb override is deferred), and on the Overslash→gateway hop the api_key is a single static Overslash-identity bearer, so the gateway never sees Overslash tenants. Reads are ordinary read (auto-approvable); send is write (gated, discloses To/From/Subject/Body). Cloud hosting is Cloud Run (one shared stateless service; Portfolio store disabled in Cloud). Two bounded Overslash core changes: an encode: base64 option on SecretRef, and multi-injection in ServiceAuth (Cloud-only: api_key + creds). See docs/design/email-integration.md.
Rationale: Overslash's executor is HTTP-hardwired (http_caller is reqwest-only; ActionRequest/ServiceAuth/secret_injection are HTTP-shaped), so native IMAP/SMTP/JMAP would mean a whole new runtime + auth scheme. A REST facade over a gateway we own keeps the entire permissions/approvals/audit/disclosure pipeline for free (per-action risk, disclose, permission keys) with no core execution change. JMAP was the initial idea but the facade decision made it a pure implementation detail, and its real-world coverage (no Gmail/Outlook) never justified the translation layer — a direct REST→IMAP/SMTP gateway is simpler and in-stack. Keeping overfwd stateless + credential-free by default preserves the "secrets never leave the vault" thesis (Overslash stays the single credential authority; the gateway is a pure function of request + credential), dissolves the two-layer multitenancy problem (a shared multitenant gateway needs zero per-tenant state), and is the sharpest wedge against EmailEngine (stores creds, Node, paid) and Nylas/Unipile (cloud-only, data flows through them). Shipping overfwd as a standalone OSS product — with Portfolio/Session as opt-in convenience tiers layered on the same stateless core — lets it stand on its own while Overslash consumes only the privacy-max Inline path.
Date: 2026-07
Decision: A service_templates row is now a layer. Migration 097_layered_service_templates adds extends text (base template key; NULL = standalone full-doc layer, today's org/user template) and delta jsonb (a derived layer's masks + extensions), makes openapi nullable, and adds a shape CHECK ((extends NULL AND delta NULL AND openapi NOT NULL) OR (extends NOT NULL AND delta NOT NULL)). A template is resolved by the fold resolve(layer) = apply(delta, resolve(extends)): the pure algebra (apply_delta/validate_delta) lives in crates/overslash-core/src/service_layer.rs; the recursive I/O walker in crates/overslash-api/src/services/template_resolve.rs unifies the two former resolve_template_definition copies so discovery, instantiation, and execution all read the effective surface (a masked-out action then returns not-found everywhere for free). Masks are monotonic/order-independent (allowlist ∩ / denylist \ / risk clamp-up-only / additive disclose / relabel / template hidden) so containment is structural; extensions are bounded (add actions/hosts, no auth, no rebinding; a key colliding with any base key — visible or hidden — is rejected at write time, and the base wins with a shadowed_extension warning at resolve time). extends is a live pointer (tracks upstream), resolved on-demand (no persistent resolved-template cache in v1). extends and key are decoupled: key == extends → shadow-with-delta (base resolved one tier up, never itself); key != extends → a distinct catalog entry alongside the base. Authority is namespace-based (no classifier): org-namespace layer → admin; user-namespace layer → the new orgs.user_template_policy enum (none | restrictive | full, migrated in place off allow_user_templates), with restrictive reserved (v1 blocks it; the classifier lights it up later with no migration). Deleting a base with live dependents is blocked (reparent/detach first). The #435 catalog gate coexists and is how an admin hides the raw global.
Rationale: Two corporate-refocus asks are the same missing primitive — curate/whitelist tools while tracking upstream, and share a prefilled service without sharing an instance. A fork (full copy) stops tracking upstream and is an unreviewed capability grant; the catalog gate is template-granularity only ("GitHub, but only 4 actions" is inexpressible). Collapsing both into a live-pointer layer with a structural delta gives curation that tracks upstream and, for API owners, bounded authoring — with containment provable by construction (the fold applies each delta to the previous layer's output, so a child can never re-expose what a parent hid). Storing the delta structurally (distinct columns, not reusing openapi) keeps the JSON self-describing and lets the deferred restrictive/expansive classifier be a pure-compute add with no future migration. Supersedes the earlier org-catalog-overlay draft (docs/design/layered-service-templates.md).
Date: 2026-07-11
Decision: New dependency versions are gated behind a 7-day cooldown before adoption, via Dependabot's native cooldown: default-days: 7 on every update entry in .github/dependabot.yml (cargo, npm, github-actions). Mirrors overfolder PR #490. Unlike overfolder, no .cargo/config.toml staging of cargo's min-publish-age (RFC 3923): that feature is nightly-only as of 2026-07 (inert on stable), and this repo deliberately gitignores .cargo/ for developers' local mold-linker config — tracking a file there would collide with every dev's local setup. See TECH_DEBT.md for the resulting manual-cargo update gap.
Rationale: Blocks the short-lived malicious-publish window — most registry supply-chain attacks are caught by scanners within days of publish, so waiting a week before adopting a new version avoids the overwhelming majority of them at near-zero cost (updates flow weekly via Dependabot anyway).
Date: 2026-07-16
Decision: Every third-party action in .github/workflows/*.yml is pinned to a 40-char commit SHA with a trailing # vX.Y.Z comment (GitHub's recommended hardening); Dependabot maintains both the SHA and the comment, so D30's cooldown now gates every Actions bump the same way it gates cargo/npm. Local ./.github/actions/* refs are path refs and stay unpinned. Pins were chosen ≥7 days old so adoption itself respects the window, and the shared actions reuse overfolder's exact pins to keep the two repos in lockstep. Extends D30 and mirrors overfolder's 2026-07-13 decision. Obsoletes PR #457 (taiki-e/install-action v2 → v2.82.9, tag-to-tag; closed unmerged — this lands the same version as an immutable SHA). Enforced by convention + Dependabot maintenance only, as in overfolder — no zizmor/pinact checker. See TECH_DEBT.md for the two pins that track branches rather than release tags.
Rationale: A floating major tag (@v7) is a mutable pointer GitHub re-resolves at CI runtime, so D30's cooldown never actually applied to Actions — Dependabot only opens a PR when it sees a new version, and nothing is proposed when the tag itself moves underneath us. A re-pointed or compromised tag reaches CI on the next run with no PR, no review, and no 7-day delay, which is precisely the window D30 exists to close. Tradeoff accepted: a (grouped) Dependabot PR per action release instead of silent tag drift.
D32: Per-scheme instance credential bindings (service_instances.credentials), not a mixed config map
Date: 2026-07-16
Decision: A service instance binds secrets per securityScheme key via a new credentials jsonb column (migration 100_service_instance_credentials): {scheme_key → secret NAME in the org vault}. Values are vault references by construction — the map holds names only, never literal values, and there is deliberately no mixed literal/secret-ref config map (the tagged {"secret": …} shape considered in D28's deferred "config jsonb" idea is rejected: a map that can hold literals makes "raw token pasted as plain config" schema-representable and needs a template-aware runtime gate to catch; separating by kind makes it unrepresentable). url stays a typed column; if a second non-secret knob appears it gets its own separate config jsonb. To label the slots, ServiceAuth::Secret (named ServiceAuth::ApiKey when this decision was taken) now carries its compiled securitySchemes key (scheme) plus the standard OpenAPI description. secret_source is reframed as fallback policy, not a binding class: every secret slot is per-instance overridable via credentials[scheme]; unbound slots fall back to the legacy scalar secret_name (instance-source) or the scheme's fixed default_secret_name in the org vault (org-source, optional skip preserved). The multiple_instance_secrets validation is gone (replaced by a duplicate-scheme-key check); templates may declare any number of instance-source schemes. API: credentials on create/update is a whole-map replace; secret_name stays as an alias for the sole instance-source scheme (400 when several exist) and is dual-written for one release so rolling deploys keep working, then the column can drop. Dashboard renders one labelled row per scheme (ServiceCredentials.svelte). No clash with D29 layers: Delta has no auth field by design — layers customize the shared definition, instance credentials fill its slots per deployment.
Rationale: email.yaml (D28) exposed the flaw twice over. The single scalar + one unlabelled picker mis-bound a real dev instance's gateway token as the mailbox credential (injected into the wrong header while the optional gateway slot was silently skipped). Deeper, secret_source: org + a fixed name was itself a workaround for single-slot storage: the gateway key is per-deployment and the deployment URL is already per-instance, so two overfwd gateways in one org need different keys — inexpressible with an org-fixed name, natural as an instance binding with an org-wide default.
D33: Non-secret per-instance values live in their own service_instances.config, gated by a template opt-in
Date: 2026-07-18
Decision: A service instance may pin non-secret parameter values in a new config jsonb column (migration 102_service_instance_config): {param name → value}. This is the separate column D32 reserved ("if a second non-secret knob appears it gets its own separate config jsonb") and closes D28's deferred Core-change #3. A value is storable only if the template marks that parameter x-overslash-instance-config: true (unprefixed alias instance-config), so the surface is template-declared rather than an open bag; unknown keys are a 400, blank values are a 400 (omit the key to unset). At execution time the map is overlaid after alias rewriting and before defaults, giving precedence caller arg > instance config > template default (D36 later inserts an org-layer default between instance config and the template default), and the eligibility check is re-applied at overlay time so a template that stops declaring a param cannot keep feeding a stale pin into requests. /v1/actions/validate applies the same overlay so a dry-run and a real call agree. The templates API exposes instance_config_params[] (deduped across actions, required = AND across occurrences) and the dashboard renders one field per entry on the instance create/edit forms. Secrets are unaffected: they stay vault references in credentials (D32), and nothing in config is encrypted.
Rationale: services/email.yaml could not reach a self-hosted mailbox at all. overfwd takes its IMAP/SMTP endpoint from X-Mailbox-Imap/X-Mailbox-Smtp request headers and otherwise falls back to autoconfig (Mozilla ISPDB + DNS SRV), which resolves only public providers — and cannot resolve anything for a login that isn't an email address. D28's interim answer was "prefilled forked templates", i.e. one fork per deployment to vary a hostname, which is a capability grant used as a config mechanism. The endpoint is a property of the deployment, not of the call: an agent has no way to know it, so it does not belong in caller args; it is not a credential, so it does not belong in the vault. Keeping literals in a column that cannot hold vault references preserves D32's central property — "raw token pasted as plain config" stays unrepresentable, with no template-aware runtime gate needed to catch it.
Date: 2026-07-18
Decision: GET /auth/dev/token accepts an optional org=<slug> (dev-gated as before). Absent, it resolves the shared dev-org exactly as it always has — every existing screenshot script and spec is untouched. Present, it resolves-or-creates that org and derives per-org profile emails (dev+<slug>@overslash.local), because find_user_identity_by_email is a global lookup and reusing the fixed addresses would have a second org's login resolve the first org's identity. dev-org is rejected as an explicit slug, and slugs are validated against the same rules as POST /v1/orgs. DELETE /auth/dev/orgs/{slug} (also dev-gated) drops the org; it is idempotent, refuses dev-org, and is implemented as a bare DELETE FROM orgs because 29 of the 30 FKs referencing orgs(id) are already ON DELETE CASCADE (the exception, users.personal_org_id, is ON DELETE SET NULL, so a users row survives as an orphan — deliberately not chased, since deleting it would reach outside the org boundary). The scenarios library exposes login(profile, { org }), freshOrgSlug() and deleteOrg().
Deliberately not done yet: playwright.config.ts keeps fullyParallel: false / workers: 1. Per-org isolation is the precondition for parallelism, not parallelism itself — the shared-dev-org specs (the majority) still collide with each other, and the GreenMail container's mail is global rather than org-scoped. Flipping workers is a separate change once enough specs have moved over.
Rationale: The dashboard e2e suite had no state isolation and knew it — playwright.config.ts pins workers: 1 with the comment "tests share dev-login users", and the scenarios README told authors to work around collisions by hand with ${name}-${Date.now()} suffixes. That makes whole categories of user story untestable: anything asserting "my services", "my secrets" or an empty state is at the mercy of whatever ran before it. Per-database isolation (the Rust suite's CREATE DATABASE ... TEMPLATE trick) is the wrong granularity here because the dashboard talks to one long-lived stack; per-org is the natural tenant boundary and the schema already made it a one-line teardown.
Date: 2026-07-20
Decision: A template's credential slots (the vault secrets an operator binds) are declared once under components.x-overslash-secrets — {slot key → {label, description, default_secret_name, source, optional}} — and are separate from the injections that read them (securitySchemes). A scheme builds its value with x-overslash-template: {lang: jq, expr: …} over those slots, e.g. '"Basic " + (.mailbox_user + ":" + .mailbox_pass | @base64)'. A slot may feed several injections and an injection may join several slots; a scheme with no template injects one secret verbatim, and always implicitly declares the slot named after itself (so the 18 single-secret templates declare no secrets block). x-overslash-prefix and x-overslash-encode are removed — along with SecretEncoding and TokenInjection::encode — and now fail template validation with a message naming the equivalent expr. TokenInjection::prefix survives for OAuth only (a live token is not a vault secret), as does SecretRef::prefix for raw-HTTP Mode A, whose documented per-call secrets[] shape has no template to carry it. jq is the one expression language in the product (it already runs response filters and disclosures), so this adds no grammar, no parser and no new syntax to document — and brings @base64/@uri/interpolation/defaults for free. Which slots an expression reads is resolved statically at template-compile time by walking jaq's public AST (overslash-core takes a jaq-core-only dependency for lexing/parsing; evaluation stays in overslash-api), stored on the compiled ServiceAuth::Secret, and narrowed into each SecretRef's bindings — so the request path decrypts exactly the secrets that header needs and hands jq nothing else. Dynamic key access (.[$k], getpath, to_entries, keys, ..) is rejected at load time, because it would defeat that analysis. Slot keys are flat strings in the existing credentials jsonb (D32), so no migration. ServiceDefinition::slots_for/all_slots own the implicit-slot rule in one place; the templates API exposes secrets[] and the dashboard renders one picker per slot.
Rationale: D28's email.yaml forced the mailbox login to be stored as a single secret whose literal value was user:pass, because encode: base64 + prefix: "Basic " were the only transforms and they applied to one opaque value. That fuses a username and a password in the vault (no independent password rotation, and neither half is separately auditable), asks the dashboard user to know a colon convention, and generalizes to nothing — the next template wanting {tenant}\{user}:{pass} or one secret in two places needs another one-off extension. Two costs of a general evaluator were addressed rather than accepted: jq runtime errors embed their operands, so the credential path never propagates jaq's message (only "template for scheme X failed"); and "user" + null is "user" in jq, so a missing slot would silently render Basic base64("user:") — a truncated credential indistinguishable downstream from a wrong password — which is why render refuses to build a value for a slot it has no value for.
D36: An org layer may preset the per-instance surface (delta.instance_defaults); a user layer may not
Date: 2026-07-20
Decision: Delta gains a third half alongside the masks and extensions: instance_defaults, carrying exactly the non-secret surface a service instance can otherwise set — url (the endpoint) and config (values for params the template declares x-overslash-instance-config, D33). Credentials, connections and discovered_tools are deliberately excluded, so D29's "a delta never touches auth" invariant is untouched. Precedence at execution is caller arg > instance.config > layer default > template default for params and instance.url > layer default > template servers[0] / mcp.url for the endpoint — a preset, never a pin, so a developer can still point one instance at a local deployment. Authority is org-tier only: a user layer setting the field is a write-time error (instance_defaults_user_tier), on both POST/PUT /v1/templates and the /v1/templates/validate-delta lint preview, since redirecting where an org's traffic lands is an org-admin decision; every tier still inherits defaults set upstream. The endpoint's origin (scheme://host[:port], not the bare hostname) is unioned into the effective hosts so the service+HTTP-verb shape's host-and-port matcher accepts the gateway without silently allow-listing :443 on it. InstanceDefaults is deny_unknown_fields — a misspelled key would otherwise deserialize to an empty struct, validate clean, and silently leave traffic on the shipped default. The declared-keys/blank-value rules move to overslash_core::instance_config, shared by the instance and layer write paths. Normalization (trailing /, whitespace) happens at fold time, not write time, so pre-existing rows fold correctly and a layer default injects byte-identically to the same value pinned on an instance. TemplateDetail.instance_defaults exposes the folded result; the layer editor renders an org-scope-only "Instance defaults" section and the instance create/edit forms render inherited values as placeholders.
Rationale: email's mailbox credential is secret_source: instance, so every user creates their own instance — and with only D33's per-instance config and the per-instance url, each of them had to paste the same org gateway URL and IMAP/SMTP host by hand. There was no org-wide default at all. A bespoke base_url field would have solved only the endpoint; generalizing to "everything an instance can pin" costs one struct instead of two mechanisms, and covers the corporate-Dovecot case (same IMAP host org-wide, different credential per user) that a URL override alone does not. It is not a rebinding in D29's sense: method, path and auth are untouched, only the origin the same operation is dialled on.
Date: 2026-07-20
Decision: Yahoo Mail is consumed through the generic services/email.yaml Mailbox Gateway template (D28) with a Yahoo app password as the mailbox credential. There is deliberately no services/yahoo_mail.yaml and no yahoo row in oauth_providers. A Yahoo mailbox is an ordinary email service instance: the app password binds to the mailbox_pass credential slot and the full Yahoo address to mailbox_user, which the mailbox scheme composes into X-Mailbox-Auth through its jq template (D35), both stored in service_instances.credentials (D32). The endpoints are either pinned per-instance via service_instances.config (D33) as X-Mailbox-Imap: imap.mail.yahoo.com:993 / X-Mailbox-Smtp: smtp.mail.yahoo.com:465, preset org-wide by an org layer's delta.instance_defaults (D36), or left unset so overfwd's autoconfig resolves yahoo.com from the Mozilla ISPDB. Zero new code, no new template, no migration. Reopen only if both of these change: overfwd gains SASL OAUTHBEARER and Yahoo grants mail-r/mail-w — either one alone is insufficient.
Rationale: A gmail.yaml-shaped template is a set of OpenAPI paths over HTTPS, and Yahoo Mail has none to point servers: at — the proprietary Mail API (JSON-RPC at mail.yahooapis.com) is retired and Yahoo's own sender documentation offers only IMAP (imap.mail.yahoo.com) and SMTP (smtp.mail.yahoo.com). Yahoo's OAuth host (api.login.yahoo.com/oauth2/{request_auth,get_token}) is real but does not help: the mail-r/mail-w scopes are not self-serve (they require a signed Commercial Access Agreement) and they authenticate IMAP/SMTP via SASL OAUTHBEARER rather than HTTP requests, while overfwd accepts only X-Mailbox-Auth: Basic base64(user:pass) and states outright that it has no OAuth flow. An OAuth Yahoo would therefore need work in a second repo (overspiral/overfwd) and a business agreement with Yahoo, for a surface the gateway already covers. Yahoo is explicitly not the Gmail/Outlook carve-out in D28: those are excluded because Google/Microsoft force XOAUTH2 for IMAP, so routing them through the gateway buys nothing over their REST APIs and loses fidelity (labels, threads, search operators). Yahoo is the mirror image — it has no REST API to lose fidelity against and still accepts an app password over standard IMAP/SMTP — which places it squarely in the "standard-IMAP long tail" the gateway was built for. See docs/design/email-integration.md.
Date: 2026-07-20
Decision: A template declares non-secret per-instance inputs under components.x-overslash-config — {key → {label, description, required}} — and a securityScheme's jq template may read them alongside its slots, e.g. email.yaml's '"Basic " + (.mailbox_user + ":" + .mailbox_pass | @base64)' where the username is config and only the password is vaulted. Values live in the existing service_instances.config jsonb (D33) and share its namespace: a config var colliding with an x-overslash-instance-config param name is a template compile error, since one key is one field on the instance form. No migration. Which reads are secret is settled statically at compile time — credential_template::partition_reads splits the AST walk's results against the declared config keys, everything undeclared stays a secret slot (the safe default), and ServiceAuth::Secret carries slots and config_keys separately because they resolve from different stores. Every D35 refusal is unchanged: a config declaration buys an expression no freedom, because anything that can reach an unnamed key can reach a secret. A scheme whose template reads only config is rejected — a header of public values authenticates nobody and belongs in parameters. Resolution is instance value > org layer's instance_defaults.config > unset; required + unset marks the scheme unresolved exactly as an unbound slot does, and render refuses a missing config value for the same reason it refuses a missing slot ("user" + null is "user", so the missing username renders Basic base64(":pass") — the D35 truncation hazard with the halves swapped). No new delta field: because a config var is a key of the same map, D36's instance_defaults.config presets it for free — an org layer sets its shared mailbox login once and every instance inherits it, with the instance still winning, and D29's "a delta never touches auth" is intact because what a layer supplies is the half that is not a credential. instance_config::configurable_keys gains the config vars, so the instance and layer write paths accept the same keys from one definition. Values ride on SecretRef.config and are therefore persisted in approval payloads in the clear, which is sound only because they are non-secret by declaration. Hard cutover, no compat shim: an email instance that bound mailbox_user in credentials now fails validation with a message telling the operator to move it to config.
Rationale: D35 fixed the vault half of email.yaml (username and password became separate secrets) but left the username in the vault, because a credential template's only possible input was a decrypted slot. A mailbox username is the public half of a login — usually the email address itself — so vaulting it buys nothing and costs a write-only dashboard field plus a second vault entry per mailbox. D33 had already built the machinery for exactly this kind of value (per-instance, non-secret, template-declared, its own column that cannot hold a vault reference), but scoped it to action params: config reached call arguments and never credential rendering. This is the smaller of the two possible fixes — the alternative, a secret: false flag inside x-overslash-secrets, keeps one block at the price of misnaming it and making every consumer branch on the flag. D32's central property survives untouched: config still cannot hold a vault reference, so "raw token pasted as plain config" stays unrepresentable.
D39: The shared Mailbox Gateway is platform-hosted, and its key comes from a platform rung below the org vault
Date: 2026-07-20
Decision: overfwd (overspiral/overfwd) is deployed once, by Overslash, as a Cloud Run service serving mailbox.overslash.com (mailbox.dev.overslash.com in dev) — the hostname services/email.yaml has always shipped as servers[0]. Terraform lives in infra/modules/cloud-run-overfwd/, gated on enable_overfwd; the image is a third-party one we deploy but do not build, so it is digest-pinned and pulled through an Artifact Registry remote repository rather than from Docker Hub at deploy time. The gateway runs with OVERFWD_REQUIRE_API_KEY=true. Because the gateway scheme is secret_source: org with a fixed default_secret_name, requiring the key would otherwise mean every org independently storing the same platform key — so the credential cascade gains one rung below the org vault: Config::platform_credential (OVERSLASH_PLATFORM_GATEWAY_SECRET_NAME / _HOST / _KEY, all three required), consulted for an org-source slot whose secret is absent from the org vault. It is pinned to a single host and matched on both the vault secret name and the effective request origin — checked at resolve time (resolve_instance_auth, deciding whether the optional scheme is emitted at all) and again at send time against action_req.url, so an approval replayed after its instance was repointed cannot carry the key out. An instance binding or an org secret still wins, so a self-hosted overfwd, a keyless one, and a per-org key all keep working unchanged. Both services read one GSM secret (overslash-{env}-overfwd-gateway-key), so rotation is one versions add plus a revision roll. The rung is deliberately one entry, not a map: there is exactly one platform-hosted upstream, and a general "fill any credential from env" mechanism is what OVERSLASH_DANGER_READ_AUTH_SECRET_FROM_ENVVARS (OAuth clients, tier 4) exists to gate loudly.
Rationale: Overfolder built a per-org deployment (#528) and backed it out (#530) for the right reason: overfwd is stateless — no credentials and no mail at rest, the mailbox login presented per request — so a per-org copy protects nothing a shared one leaks, while multiplying cost, keys to rotate and images to patch. That makes the gateway a platform service, and a platform service cannot ask its tenants to hold its key; the alternative (require_api_key=false) would put an unauthenticated IMAP/SMTP proxy on the public internet. Host-pinning is the load-bearing part: instance.url is tenant-controlled (D36 lets an org layer redirect every instance in the org), so a rung that filled the slot regardless of destination would hand the platform key to any host a tenant named. SSRF posture: a shared gateway takes endpoint headers from every tenant, and overfwd's filter (autoconfig.rs::is_public_domain + a loopback/private-refusing resolver) originally covered only the domain-derived path — an explicit X-Mailbox-Imap: host:port was parsed by auth.rs::HostPort::parse with no address check at all, deliberately, so the local GreenMail stack works. That gap was closed upstream in overfwd v0.3.0 (OVERFWD_BLOCK_PRIVATE_ENDPOINTS, off by default, which this deployment sets to true): a target that is or resolves to a loopback / RFC1918 / link-local / CGNAT / IPv6-ULA address is refused before anything is dialled, so a hostname whose A record points inward is caught too. Hence the image is digest-pinned at v0.3.0 or newer. Two further layers stand behind it: the service is attached to no VPC connector (it also needs plain egress to IMAP 993 / SMTP 465, which a connector without NAT would break), so "internal" is the container and the GCP metadata endpoint and nothing of ours; and the endpoint headers are x-overslash-instance-config, i.e. org-admin-set rather than agent-supplied. See docs/design/email-integration.md and docs/runbooks/mailbox-gateway.md.
D40: Permission keys carry a scope label ({service}:{action}:{label}={value}); value-only patterns stay valid and label-agnostic
Date: 2026-07-21
Decision: x-overslash-scope_param accepts a param name, a param:label pair, or a list of either. Each scoped value mints {service}:{action}:{label}={value} (label defaults to the param name), keys are deduped, and services/email.yaml's send now scopes [to:recipient, cc:recipient, bcc:recipient]. On the matching side, a key answers to two forms: itself, and its value-only form with the label= prefix stripped — so email:send:*@example.com covers recipient=a@example.com, while email:send:cc=*@example.com covers only the cc label. A prefix counts as a label only when it is a bare identifier, so a value containing = is never sliced.
Rationale: scope_param named one param, so cc/bcc recipients consumed no permission key at all: a grant for email:send:*@example.com gated the to line and let a bcc go anywhere — the gap email.yaml had carried as a comment since the send action shipped. Fanning out over several params closes it, but raises a second question the flat arg cannot answer: are to=a@x.com and cc=a@x.com one decision or two? Both answers are right for different templates, so the label is author-controlled. Params that mean the same thing to the person granting access share a label and collapse (one address on two headers is one approval); params that don't stay distinct and can be granted separately. Labelling the arg is what makes that expressible at all — an unlabelled union could only collapse, and an unlabelled fan-out could only split. Back-compat is the load-bearing constraint on the matching rule: keys are stored on approvals and audit rows and are typed by hand into grants, so changing the derived shape had to leave every existing rule matching. Hence value-only patterns match any label rather than being migrated, and the broadening ladder offers the value-only form as its own rung ("this correspondent, whichever header") between the exact key and {service}:{action}:*. Templates whose recipients are arrays of objects (outlook, google_calendar attendees) cannot use this — scoping them needs a value extractor on the scope entry, tracked in TECH_DEBT.md. The compat rule is deliberately one-way: a value-only pattern covers a labelled key, but a label=-qualified pattern does not cover a label-less one. That direction is reachable — approvals persist their derived keys and cascade_resolve re-matches those stored strings against rules written later — so an approval filed just before this shipped (email:send:a@example.com) is not auto-resolved by a rule remembered just after (email:send:recipient=a@example.com). It cannot be: the stored key records no label, so nothing says whether that address was a to or a bcc, and honouring a cc=-scoped grant over it would grant more than the operator wrote. The cost is a human click on approvals filed inside one APPROVAL_EXPIRY_SECS window (30 min by default), after which the population is empty; the alternative is silently widening a narrow grant.
D41: Members are identities; invites and impersonation both pre-create them, and first sign-in adopts by email
Date: 2026-07-21
Decision: A person belongs to an org iff a kind='user' identity for them exists in that org — there is exactly one such identity per person per org, and the former org_invites table is dropped (migration 103). A pending invite is now that identity with external_id IS NULL ("belongs to the org, never signed in"); an admin invite additionally carries is_org_admin + Admins-group membership. Three paths create the same row: an admin invite (/v1/org-invites, reimplemented as a projection over identities, same wire shape), name-based impersonation (X-Overslash-As: alice@acme.com[/agent/...] — an impersonation-scoped key provisions the user, and any named agent chain, on demand), and a first SSO sign-in. provision_org_subdomain gained an adopt-by-email branch after the (org, external_id) short-circuit: it finds the pre-created identity by verified email and stamps the IdP subject onto it instead of forking a second identity, so the person lands on their pre-created agents, connections, and audit history. This subsumes and deletes both the old existing_member (users-by-email) short-circuit and the org_invite::find_pending/mark_accepted gate. orgs.require_invite_admission keeps its exact meaning — with it on, a verified email with no pre-created identity is rejected not_invited; the domain-allowlist and legacy per-provider-IdP admission branches are unchanged and still mint fresh identities. A second IdP for the same email re-points that identity's external_id (one human = one identity per org). Auto-created identities are unprivileged by construction (inherit_permissions = false, plain member) and impersonation auto-creations are audited identity.provisioned.
Rationale: A pending invite (org_invites(email, role, accepted_at) — no token, no expiry) carried exactly the information a pre-created user identity already can: an email, an admin bit, and a "has it been claimed" flag (external_id IS NULL). Keeping both meant two representations of "this person may join", and the impersonation feature — which must let a white-label backend (Overfolder) name a user by email and have them JIT-provisioned — only pays off if that provisioned identity is the one the person later signs into, rather than being orphaned beside a fresh SSO identity. Making admission key on the identity (not on a users+membership pair) is what unifies the two: invite, impersonation, and sign-in all reduce to "ensure the user identity exists, then attach the IdP subject". The one deliberate behaviour change is that a second IdP now lands on the same identity rather than forking a second one onto a shared users row — which is what the agents, connections, and Myself group per identity already assumed. external_id stays pinned to the subject that originally claimed the identity and is never rewritten by a later provider: a flip-flopping subject would make the (org_id, external_id) fast path miss on alternating logins and make connect_gate's cross-org subject match depend on whichever IdP was used last. Security posture is unchanged: the old gate also admitted on verified-email match, and the (org_id, external_id) unique constraint keeps the first link safe. The /v1/org-invites endpoints are retained as a compatibility projection because external callers depend on them. See §4 of SPEC.md.
D42: SQL-bearing tool params get a field-nominated, Postgres-exact parse policy (pg_query); read/write + table rules enforce in Overslash, column masking pushes to the DB
Date: 2026-07-24
Decision: For tools that take a raw SQL string — first the Metabase MCP's execute/export (query), later HubSpot query_crm_data, Shopify ShopifyQL, etc. — Overslash gains a content policy driven by two field-level x-overslash-* annotations, not a hardcoded per-service code path: (1) x-overslash-sql: true marks the string param that carries the query (deliberately not a synthetic OpenAPI type/format: sql — 3.1 has no such type and we don't invent one; it's an extension on an ordinary string, normalized via openapi/alias.rs like every other x-overslash-*); (2) x-overslash-sql-database: <jq expr> names a jq expression over the call params (single field .database_id, or a composition like .project + "/" + .dataset) whose result is looked up in per-instance config (x-overslash-instance-config/x-overslash-config, D38) to resolve the dialect and a human DB label for audit — unresolved falls back to postgres, fail-closed. Parsing uses the pg_query crate (Rust bindings over libpg_query, Postgres's own C parser), chosen over pure-Rust sqlparser-rs because correctness of read/write + table/column is the point and libpg_query parses anything Postgres accepts identically and exposes .tables()/column refs/statement types directly; it is gated behind a sql_policy Cargo feature, off by default (it adds a C-toolchain build dep and binary weight, which the default build must not pay). Enforcement scope is deliberately asymmetric and honest: read-vs-write is classified from the parse tree (fail-closed — SELECT/WITH-only → read; DML/DDL/TRUNCATE/COPY/multi-statement/writable CTE/DO/CALL/unparseable → write), elevating the action's otherwise-static Risk so writes route to approval bubbling; per-table rules are enforceable by deriving one permission key per referenced relation, DB-label-scoped, reusing the existing scope_param key shape (metabase:execute:table=reveni-prod/public.orders) and glob rule engine with no new grammar; column rules are fail-closed detection only — SELECT * surfaces * as a literal column name so a deny-* rule forces explicit enumeration (after which listed columns bite), but views/CTEs hide base-table columns from any parser, so true column masking (PII) is pushed down to Metabase data-sandboxing / column DB grants, never claimed as an Overslash-side guarantee. A read-only Metabase key/group remains the backstop regardless (parse = belt, key = suspenders). Sequencing: audit first — ship services/metabase.yaml with per-operation actions and a disclose filter capturing the raw query (Reveni's only near-term need, no parser required) — then the classifier, then per-table keys, then column rules. Design notes: docs/design/metabase-mcp.md.
Rationale: The Metabase MCP's own read-only guard is a client-side regex that Metabase does not enforce (see the design doc); asking "should Overslash re-implement per-table/column authorization by parsing SQL" splits cleanly once you separate what a parser can guarantee from what it can only detect. Statement kind and referenced table names are structurally present in the parse tree, so read/write routing and per-table gating are real, enforceable wins that beat both the regex and a read-only key for approval routing — and they land on the existing risk→approval and scope_param→glob machinery with almost no new surface. Resolved column identity is not structurally recoverable without catalog resolution (SELECT *, view expansion, CTE aliasing), so promising column masking in the gateway would be security theater; the DB/Metabase own that boundary because they own the schema, and the *-as-column convention keeps the gateway honest by converting the unknowable case into a forced-enumeration deny rather than a silent hole. Choosing libpg_query over sqlparser-rs trades a C build dep (contained by the default-off feature) for Postgres-exactness, which is the whole value proposition here; the dialect is resolved from the nominated DB field rather than pinned per action precisely so a second backend (sqlparser-rs for best-effort dialects, or another engine) can be added later without touching the rule surface. Making the policy attach to fields rather than to a Metabase-specific handler keeps it a generic gateway capability (CLAUDE.md Rule 4) — Metabase is just the first template to set the two annotations. Auditing first matches the customer's actual near-term ask and defers all parser/build cost until the enforcement tiers are wanted.
D43: One x-overslash-sql-field annotation nominates the SQL param and its body path; risk: dynamic is explicit; column rules are deny-screen only
Date: 2026-07-24
Decision: D42's two-annotation sketch ships as one field annotation plus one declared-risk value. (1) x-overslash-sql-field: <dotted-path> on a param both marks it as the raw-SQL field and names the SQL string's location in the assembled JSON body. Two modes fall out of the param's type: a string param is placed at the path (query with native.query sends {"native":{"query":…}} while the caller surface stays flat — Metabase run_query), and an object param is descended into (the path anchors at the param name: query with query.native.query reads the SQL inside the caller-supplied dataset object — Metabase export_query, whose upstream endpoint takes the query as one nested object). A raw non-JSON body (Content-Type: application/sql) is not representable — the assembler only emits JSON built from params — and is deliberately out of scope until a raw-body content type exists. The separately-sketched generic x-overslash-body-path is not shipped: its only consumer was the SQL param, and the sql-field path subsumes it; introduce it independently if a non-SQL param ever needs nesting. x-overslash-sql-database stays as decided (jq over call params → sql_databases config var, exempted from the "config must be read by a scheme" gate). (2) risk: dynamic is a fourth declared-risk value (DeclaredRisk; the runtime Risk stays a tri-state): template validation rejects it on an action with no sql-field param (dynamic_risk_without_sql), static/display contexts resolve it as write ("write until proven read" — listings, approval-card fallback, the mutating-actions-declare-disclose gate, layer-fold clamping), and at call time it starts from read and merges the classifier's verdict as a floor via Risk::max_severity — a build without the sql_policy feature, an unsupported dialect, an unparseable statement, or an sql param the caller never supplied all resolve to write. A static risk with an sql-field param is also legal: the classifier can only elevate it. (3) Per-table keys are split by context: relations referenced in select context mint {service}:{action}:table={label}/{relation}, mutation targets (DML/DDL context, straight from the parser's per-relation tagging — select_tables() vs dml_tables()/ddl_tables()) mint table_mut={label}/{relation}, and the all-tables sentinel is mutation-shaped (table_mut={label}/*) because every non-enumerable case also classifies write. A relation both read and mutated (INSERT INTO a SELECT * FROM a) carries both keys. The two labels are disjoint on the matching side — a rule for one never covers the other — while D40's label-less value-only form ({service}:{action}:{label}/{relation} stripped of table[_mut]=) covers both, giving the broadening ladder a "this table, read or write" middle rung. This is what keeps Allow & Remember honest (a rule remembered from a read approval never silently authorizes later mutations of that table), makes asymmetric policies expressible (allow table=pagila/* + allow table_mut=pagila/public.scratch), and makes write-only denies possible (deny table_mut=pagila/* leaves reads untouched). (4) Column keys are deny-screen only (check_permissions_screened): named identifiers mint {service}:{action}:column={label}/{ident} and a star select mints {service}:{action}:column_star={label} — its own label, because a glob pattern cannot name the literal * without matching everything, so "force enumeration" is the typable deny {service}:*:column_star=* and per-column denies stay independent ({service}:*:column=*/ssn). Screen keys can trip a deny at any chain level but never need allow coverage and never appear in approvals. A deny overrides every allow mechanism: SQL-classified calls run a deny-only chain sweep (denied_anywhere) even on the paths that skip the full walk — the auto_approve_reads read bypass and the users-approve-themselves rule — while table-key allow coverage is deliberately still bypassed by auto_approve_reads for read-classified calls (a blanket read grant covers the service's read surface; the table tier gates agents without one — and a write-classified call never takes the bypass, so table_mut= coverage is always walked). Table keys append to the scope_param keys but replace the bare {service}:{action}:* fallback (no table rule can cover :*, so keeping it would collapse the tier into "grant the whole action"), and the broadening ladder gives slash-carrying labelled keys ** rungs (table=db/* → {service}:{action}:** → {service}:**) because * does not span /.
Rationale: The merge came from asking where SQL can actually live in a request: directly as the body, as a flat string body param, or at a path inside a JSON object param. One dotted body path answers "which param" and "where in the body" for all representable cases, so two annotations would encode one fact twice — and the annotation doubles as caller-surface control, keeping Metabase's run_query flat and agent-friendly while matching the upstream's nested payload byte-for-byte (verified against a live Metabase + Pagila stack; the export endpoint's nested-object contract is exactly what forced extraction mode). dynamic is declared rather than inferred (a static read + parse-elevation would look identical at call time) because the template should say out loud that an action's class is decided per call — validation can then demand the sql param, catalogs can render it, and a fail-closed resolution exists for every path that produces no verdict. column_star exists because D42's "deny-* forces enumeration" was unimplementable in the glob engine as literally written. The read/mut split closes a hole the single-label shape shipped with for a few hours: one table= key meant a rule remembered from a read approval covered later mutations of the same table at Layer 2 (the human approved a SELECT and durably authorized INSERTs, gated only by the coarse per-service ceiling), and neither "read anything, write only scratch" nor a write-only deny was expressible. The parser already tags every relation with its context, so the split costs one more label and nothing else; it was done before any table= rules existed in the wild, so no compat story was needed — and it mirrors how the database itself models the boundary (GRANT SELECT vs GRANT INSERT). The deny-sweep-under-bypass rule resolves a real conflict surfaced by tests: auto_approve_reads skips Layer 2 wholesale, which would have silently skipped PII column denies too; "deny rules override allow rules" is the documented contract, so the bypass stays an allow-only fast path. Sequencing note: all D42 tiers shipped together (template, classifier, table keys, column screen) — the audit-first cut proved unnecessary once the classifier landed in the same change. See docs/design/metabase-mcp.md.
D44: A template resolves deployment-specific values from ${VAR}, sourced only from OVERSLASH_TEMPLATE_VAR_*
Date: 2026-07-30
Decision: A service template may write ${NAME} or ${NAME:default} in any string value, resolved at compile time from environment variables under the single prefix OVERSLASH_TEMPLATE_VAR_ (NAME is [A-Z][A-Z0-9_]*, the prefix is stripped, $${ escapes a literal ${, anything else after $ is copied verbatim so jq and prose survive). Unset with no default is a compile error (template_var_unset), never an empty string: shipped templates log-and-skip exactly as they do for any other load failure, POST /v1/templates/validate reports it to the editor, and the message names the environment variable to set. A third form, ${NAME?}, defaults to null — unset replaces the whole value (it must be the whole value; template_var_optional_not_whole otherwise) rather than erroring. The three forms map onto three genuinely different situations, and the two shipped templates use two of them. services/email.yaml's servers[0] is https://${MAILBOX_HOST} with no fallback: a self-hoster has no Overslash gateway, so both a literal default and a null would be wrong, and unset correctly means "this deployment has no email template". services/metabase.yaml's is ${METABASE_URL?}: Metabase is self-hosted, so no host is honest for every deployment, but dropping the template would be wrong too — the deployment merely doesn't know the URL, and the operator does. Null makes extract_hosts skip the entry, leaving a host-less template, which is the pre-existing "operator supplies the endpoint at instantiation" shape (telegram, whatsapp, every MCP template). configurable_url therefore also returns true for any host-less template (except the http pseudo-service, whose callers pass a full URL per call), and kernel_create_service requires url for a host-less HTTP template unless a layer's instance_defaults.url supplies one — the HTTP twin of the check MCP templates already had. One consequence had to be closed at the same time: resolve_verb_host_and_path read hosts: [] as unbound ("the caller names the target on every call"), which was safe only while http was the only host-less HTTP template. A named service compiling host-less would otherwise have become a raw-HTTP escape hatch reachable at any host — the binding gap D14 removed Mode B to close. The unbound branch is now keyed on registry::HTTP_PSEUDO_SERVICE rather than on emptiness, and every other host-less template refuses the verb shape and directs the caller to its actions. A literal default belongs only on a variable that means something in a standalone deployment; neither shipped template qualifies. Expansion runs for org- and user-authored templates too, not just shipped ones, and always on the parsed document's string values (template_vars::expand over serde_json::Value), never on YAML source: a value carrying " or a newline therefore cannot restructure a document, which is what makes the tenant-authoring case safe rather than merely validated. Object keys and non-string values are untouched. The stored document keeps its references unexpanded — expansion happens at load (registry::load_from_dir) and at resolve (template_resolve, embedding_backfill), so one row follows whichever deployment reads it instead of freezing the authoring one's hosts. Terraform derives OVERSLASH_TEMPLATE_VAR_MAILBOX_HOST from the same overfwd_domain that feeds platform_gateway_host, so the template host and the platform-key host gate cannot drift; a template_vars map carries any others. GET /v1/templates/vars lists the configured set, names and values, to any authenticated caller. Layer deltas are not expanded — instance_defaults.url is an org-authored literal, not a template.
Rationale: email.yaml shipped the literal mailbox.overslash.com while dev deployed mailbox.dev.overslash.com, and infra/variables.tf documented the coupling as a rule humans had to keep ("must match servers[0]"). It was not being kept, and the failure was doubly silent: a dev default instance sent mail through the prod gateway, and because Config::platform_credential_for matches the host for exact equality (D39), it was also denied the platform gateway key and sent a bare unauthenticated request. Neither existing knob reaches this — OVERSLASH_SERVICE_BASE_OVERRIDES is SSRF-gated to loopback by design and rewrites the URL after the host check, and D36's instance_defaults.url is per-org DB state that cannot fix a shipped global default. Both halves derive from hosts[0], so the fix had to land before extract_hosts, i.e. at compile. The prefix is the security boundary, and it does double duty. It makes DATABASE_URL, SECRETS_ENCRYPTION_KEY and every other variable structurally unnameable — there is no syntax that reaches them — which is what allows the same mechanism to serve tenant-authored templates without becoming an environment-read primitive. The price, accepted explicitly: every value under the prefix is readable by any tenant who can author a template (write ${FOO} into a servers[].url, read the resolved definition back), so it is a non-secret-by-declaration namespace like service_instances.config under D33 — hostnames and base URLs, never a credential. Gating the listing endpoint would have been theatre for exactly that reason. Deliberately not a per-org setting: this is a fact about the deployment, identical for every org on it, and an org that genuinely wants its own gateway already has instance.url and instance_defaults.url (D36). The null form exists because the first cut had only the two ends — a literal default or a hard failure — and self-hosted Metabase fits neither: http://localhost:3033 is a plausible-looking wrong answer for every deployment that is not the dev harness (and silently pointing an instance at localhost is worse than asking), while failing the load would mean a deployment could not offer Metabase at all without a platform-level env var for a URL only the instance's owner knows. Null is not a fourth resolution tier, just an absent value handed to machinery that already existed for host-less templates. One pre-existing hazard is unchanged and worth naming: a tenant template naming default_secret_name: overfwd_gateway_key on the platform gateway host receives the platform key, exactly as it could by hardcoding that host before — template variables only save it from knowing the string.
D45: The real-time stream is SSE over a durable events table fanned out by Postgres LISTEN/NOTIFY; events carry a frozen audience and are never org-broadcast
Date: 2026-07-31
Decision: GET /v1/events/stream?topics=... ships as Server-Sent Events, not WebSocket, with a fixed 30-second connection ceiling and Last-Event-ID resume per SPEC.md §10. Every event is appended to a new events table whose BIGSERIAL id is the resume cursor (WHERE id > $cursor), alongside a stable event_id uuid for the wire envelope; an AFTER INSERT trigger pg_notifys only that id on the overslash_events channel, and each replica's PgListener task fetches the row and republishes it on a process-local tokio::sync::broadcast. Writers never publish to the bus directly, even same-replica: one delivery path means one ordering and one dedupe rule, and no divergence between the single-replica case under test and the multi-replica case deployed. Visibility is an audience uuid[] column resolved once at emit time and frozen — chain(requester) ∪ chain(current_resolver) for approvals (mirroring mine/assigned/actionable), chain(owner) ∪ {actor} for connections (not the owner's descendants), chain(requested_by) ∪ chain(target) for secret requests — with org admins bypassing it. The delivery predicate (org AND topic AND (admin OR audience ∋ me)) runs in SQL on the replay path and in memory on the live path. An identity-bound credential is required (403 otherwise), and there is no ?token= query-param auth mode. A single services::events::emit seam appends to the log and calls the webhook dispatcher, replacing all 8 hand-rolled tokio::spawn { dispatch(...) } call sites and the 9 event-name string literals (now an EventType enum); secret_request.created/.fulfilled are added to both transports, with the provide token/URL deliberately excluded from the payload. The approval taxonomy carries three events for "someone must decide": approval.created (raised), approval.bubbled (moved between resolvers, via: user|auto), and the derived approval.pending (is it waiting on me now), which fires after creation and after every hand-up so an inbox consumer subscribes to one type rather than reconstructing the answer from two shapes. pending goes to the stream and to webhooks but deliberately not to the audit log — it restates a fact the other two already recorded, and a row per gated agent call would be pure volume; the audit log records facts, the stream delivers notifications. Ordered sequences go through emit_all, which appends the whole batch within one task before dispatching any webhook, because two independent emit calls would race and could deliver a derived signal ahead of its cause. Backpressure is handled by ending the connection on broadcast Lagged — the client resumes from its cursor and Postgres serves the backlog. Streams are capped per identity (4) and per org (64) per replica, refused with 429 + Retry-After. Design notes: docs/design/event-stream.md.
Rationale: SSE over WebSocket because the traffic is strictly one-way server→client — there is no client→server message to justify a bidirectional protocol — and SSE survives the vercel.json rewrites and Vite dev proxy unchanged, gets Last-Event-ID resume from the browser for free, and already had an in-repo precedent (routes/mcp/elicitation.rs). The 30-second ceiling is not a limitation to work around but the mechanism that makes resume trustworthy: the reconnect path runs twice a minute in production instead of being exercised for the first time during an incident, and no proxy in the path gets to decide the timeout for us. LISTEN/NOTIFY over Redis pub/sub because resume demands a durable, replayable log regardless of transport — webhook_deliveries could not serve (per-subscription rows, none at all for an org without a webhook, no record of who could see what) — and once that table exists, Postgres is already a mandatory dependency on the write path while Redis is optional and deliberately fails open for rate limiting; making real-time delivery the first thing to hard-depend on Redis would have inverted that posture for no gain. Only the cursor crosses NOTIFY, so the 8 KB payload ceiling is irrelevant and no event body lands in pg_stat_activity. Audience is frozen at emit rather than re-derived per subscriber because the emitting path already holds the identity chains (one query, not one per subscriber) and because an event is a historical fact — re-deriving would let tomorrow's re-parenting change who could see what happened today. Audience is deliberately narrower than GET /v1/approvals, which today has no ACL gate and lets any identity list every pending approval in its org: that is a known gap, and a new fan-out surface is the wrong place to reproduce it. Connections exclude the owner's descendants because sub-agents use an owner-level connection via on_behalf_of but cannot list or manage it, and an event stream must never be wider than the read model it reflects. Query-param tokens were rejected despite EventSource being unable to set headers: a credential in a query string lands in access logs, proxy logs and Referer, and it is unnecessary because the dashboard reaches the API same-origin through proxies so a plain EventSource carries the oss_session cookie (the corollary — SameSite=Lax means a direct cross-origin EventSource would not — is why the stream must stay on the relative proxied path). The single emit seam is what makes SPEC §10's "same payload regardless of transport" structural rather than aspirational; the dashboard keeps its polling as a live fallback (ticks skipped while the stream is up) so an environment that breaks SSE degrades to exactly the behaviour that shipped before.
D46: The widget SDK is one @overslash/sdk package — headless controllers plus custom elements, no framework bindings
Date: 2026-08-01
Decision: Third-party integration ships as one npm package at sdk/, with subpath exports (., ./controllers, ./elements, ./node, ./format) rather than separate core/ui packages, and with zero runtime dependencies. The UI layer is custom elements only — there is no @overslash/react, no @overslash/svelte, and none is planned; a host that wants its own markup composes the headless controllers, which are framework-free state machines over a Store<T> (getState/subscribe/dispose) that is useSyncExternalStore's contract verbatim and wraps into a Svelte readable in three lines. The controllers are ports of logic that exists today and is reachable only from Svelte: lib/approvals/resolution.svelte.ts (optimistic override, execution states, stream-driven refetch with the 30s×1.5s poll as fallback), lib/approvals/format.ts (206 lines of pure display helpers), lib/oauth-connect.ts (popup-and-poll). POST /v1/actions/call is always sent with ?wrap=true, so pending_approval is a return value carrying everything a card renders rather than an exception to catch, and waitForApproval exists because the single-agent archetype's run loop cannot pause — an approval must block inside the tool's execute().
Rationale: Two consumers with incompatible stacks (Svelte 5 + Tailwind; React 19 + plain JS + hand-rolled tokens) and a third that is whatever a vanilla page is. Framework bindings would mean versioning N packages against one wire protocol to save each host three lines of glue, and custom elements are the only UI form all three consume natively. One package rather than two because the halves version in lockstep and subpath exports already give the tree-shaking boundary splitting would buy — importing the client never pulls element code. Zero dependencies is a hard constraint, not taste: the first target consumer's contributing rules forbid adding dependencies without asking, and everything needed is reachable from fetch, WebCrypto and the DOM. Deliberately not migrating the dashboard onto the SDK in the same change: it is the best possible dogfood and the type names are shaped for it, but tying the SDK's first release to a dashboard-wide refactor would inflate the review surface of both. See docs/design/widget-sdk.md.
D47: SDK wire types are hand-written mirrors of the Rust DTOs; the SSE and webhook envelopes are one type
Date: 2026-08-01
Decision: The SDK's TypeScript types are hand-written, each carrying a /** Mirrors <rust path> */ header, following the discipline dashboard/src/lib/session.ts and types.ts already use. Codegen is deferred, and explicitly blocked on a prerequisite: the gateway API has no OpenAPI description (the services/ YAML documents third-party services, not Overslash), so generating a client means first annotating every route and DTO in crates/overslash-api with utoipa. The EventEnvelope/EventType pair is shared between the SSE stream and webhooks rather than duplicated per transport.
Rationale: A mirror that drifts is the obvious risk, and it is bounded by making the source explicit in every type's doc comment so a PR touching a Rust DTO can grep for its dependents. The alternative is not "generated types now" but "no SDK until the API is spec'd", which trades a maintained convention for an indefinite block. One envelope type because D45 made the two transports byte-identical on purpose — "the same event payload regardless of transport" — so expressing them as two types would encode a difference that does not exist and would let one drift from the other. It also means a consumer that already verifies webhooks needs no second parser for the stream.
D48: Widget UI renders in open shadow DOM, themed only through --overslash-* custom properties and ::part
Date: 2026-08-01
Decision: Every element renders into an open shadow root with a constructable stylesheet (a <style> clone as fallback). Branding is a documented --overslash-* custom-property contract; every structural node carries a part for overrides ::part() can reach. There is no light-DOM mode. Registration is explicit via defineOverslashElements({ prefix }) — importing the module is side-effect-free — so SSR does not break and two SDK versions can coexist on one page under different tag names. Copy is overridable per element via a strings property merged over English defaults; no i18n framework.
Rationale: The three target hosts carry three different global CSS regimes (Tailwind v4 with hardcoded dark hexes; hand-written tokens plus BEM with an explicit ban on hardcoded values; whatever a vanilla page is). Light-DOM markup inherits all of them and renders differently in each, which for a component whose job is asking a human to authorise something is not a cosmetic problem: a misrendered risk badge or a collapsed disclosure list changes what the person is agreeing to. Shadow DOM is the only way one stylesheet behaves identically everywhere. The standard objection is answered rather than dismissed — custom properties inherit through the boundary, so brand theming needs no piercing at all, and part covers the rest. A host that genuinely needs its own markup is not stuck: the headless controllers are the supported answer (D46), which is why a configurable light-DOM mode would only double the test matrix for a case already served.
Date: 2026-08-01
Decision: SseEvents parses GET /v1/events/stream out of a fetch response body (ReadableStream → decode → SSE line parsing) rather than using EventSource, behind an EventsTransport interface whose other implementation, PollingEvents, does bounded 1.5s polls. The client opens one connection and multiplexes subscribers over it, subscribing the union of their topics; poll ticks are skipped while the stream is live. Reconnect semantics follow the dashboard's: the routine 30s close is not an error, a ~8s grace window precedes reporting down, backoff is jittered 1s→30s, and Retry-After is honoured on 429. The SDK owns the resume cursor, updating it per frame as the server's replay ordering requires, so stream.resync is emitted only when reconnecting having never received a cursor. Events are treated as notifications: controllers refetch the resource an event names and never render from the payload.
Rationale: Two of the SDK's three auth modes are bearer modes, EventSource cannot set an Authorization header, and D45 deliberately refused a ?token= query-param mode because a credential in a query string lands in access logs, proxy logs and Referer. That alone forces a hand-rolled parser; owning it then turns out to be strictly better. EventSource holds its cursor internally, so a fatal error destroys it and the reconnect starts blind — which is exactly why the dashboard must synthesise stream.resync and make every subscriber refetch. Holding the cursor ourselves makes reconnect after any failure precise, and reduces resync to the genuinely blind case. One multiplexed connection because the per-identity cap is 4 concurrent streams: a page with an approval list, two cards and a connect button would otherwise exhaust it against itself. Polling is retained rather than treated as legacy because it is what makes the SDK work in transport mode against a host proxy that does not forward streaming responses, and against a server older than D45.
D50: Browser credentials are short-lived aud=widget tokens minted by the host backend; X-Overslash-As never reaches a browser
Date: 2026-08-01
Decision: A browser widget authenticates with a stateless HS256 JWT, aud=widget, minted at POST /v1/widget-tokens by a host backend holding an impersonate-scoped API key, with X-Overslash-As selecting (and provisioning) the end-user identity exactly as elsewhere. TTL is clamped to [60, 3600], default 900s; claims are sub, org, key_id, impersonated_by, caps, origins. There is no refresh endpoint — the host re-mints from its own authenticated endpoint. A static (method, path) allowlist keyed on caps is enforced in the extractor, fail-closed, and reached through a dedicated /widget/* router subtree carrying allow_credentials(false) permissive CORS, leaving cors_global untouched; the real origin restriction is the origins claim, checked at authentication. With a widget token present, GET /v1/approvals defaults to ?scope=actionable and refuses anything outside mine|assigned|actionable. Resolution authority is unchanged — WriteAcl plus classify_approval_relationship under the impersonated identity, self-approval still impossible without an MCP client binding. Rate limiting extends to aud=widget. X-Overslash-As is not added to any CORS allow-list. Until this ships, browsers use the SDK's host-proxy transport mode; the SDK's { token: () => … } signature is stable either way.
Rationale: osk_ keys are org-wide secrets and the oss_session cookie is SameSite=Lax, so today the only browser-safe path is a proxy every integrator must build first. A per-identity, capability-narrowed, 15-minute token is the smallest credential that removes that requirement, and it composes with the existing impersonation machinery instead of adding a parallel one — the mint path inherits the ACL cap (target ≤ minting identity), so a widget token can never exceed what the host key could already do directly. The caps allowlist is the XSS blast radius, and stating it that way is what keeps it honest: a stolen token can act on one identity's approvals for at most fifteen minutes and can read no secret, mint no key and impersonate nobody. The listing pin exists because GET /v1/approvals still has no ACL gate and any identity can enumerate its org's pending approvals; a new browser-facing credential is the wrong thing to hand that gap to, and D45 already refused to inherit it on the stream. No refresh endpoint because the host's session — not Overslash — is the authority on whether that user is still logged in, and a refresh endpoint would re-implement that check with less information. Accepting a function for the token in the SDK is what makes a 15-minute TTL invisible to the integrator. X-Overslash-As stays server-only because it is meaningful only alongside an osk_ key, and a widget token names its identity in its own claims, leaving the browser nothing to assert.
D51: Binary payloads leave via a capability URL minted per request (deliver: "url"), never inline over MCP
Date: 2026-08-04
Decision: POST /v1/actions/call takes deliver: "inline" | "url", defaulting to inline, and the flag is exposed on the overslash_call / overslash_read MCP tool schemas. With deliver: "url" the caller gets a descriptor — {download_url, expires_at, mime?, size_bytes?, filename?} — and the bytes move out of band via GET /v1/downloads/{token}, which is unauthenticated by design: the token is 256 bits of randomness, stored only as a SHA-256 hash, short-lived (DOWNLOAD_TOKEN_TTL_SECS, default 900s), and multi-use until expiry so a resumed or retried transfer works. The row stores a credential-free ActionRequest plus an identity, never a resolved credential — a raw-HTTP call passing a credential in an inline header is rejected at mint, since that is the one shape whose headers are caller-supplied; both the credential and the identity's existence are re-checked at fetch time. HTTP-runtime actions need no declaration — the action is the download, so the token captures the resolved request. MCP-runtime actions declare x-overslash-download (jq filters over the tool result: url required, mime/size/filename optional, auth: inherit|none), and the resolved location must be same-origin with the MCP server's own URL. OAuth-authenticated services are refused (400) — their credential is minted live and AuthHeader has no Serialize precisely so it cannot be persisted. That gate reads oauth_injected, not auth_header.is_some(): a template declaring a query-param token injection resolves OAuth successfully but builds no header, so the header check would read as "no credential needed" and mint a token whose fetch carries nothing. Combining deliver: "url" with filter or prefer_stream is a 400, as is a raw-HTTP call passing a credential in an inline header. Mint writes action.deferred (HTTP runtime only — MCP already wrote action.executed), redemption writes action.downloaded. Inline prefer_stream stays exactly as it was; both paths now share one streaming-response builder.
Rationale: Overslash could return bytes exactly one way — prefer_stream: true — and that is a REST-DTO field the MCP dispatch fork returns before ever reaching. The buffered path MCP did reach ran every body through String::from_utf8_lossy and cropped strings at 200 chars in compact mode, so binary over MCP wasn't awkward, it was unreachable. It should stay unreachable: an agent that wants a 40 MB video does not want it in a context window, it wants it on disk — the motivating caller fetches with curl -o from a sandboxed VM that has network egress but deliberately holds none of the caller's credentials (build_scrubbed_env). A capability URL is the only representation of a file that satisfies both, and putting it at the Overslash level rather than in each upstream generalizes to services we don't control (Drive's get_file_content already compiles to response_type: "binary", which nothing read until now) and keeps the permission check and audit trail where they already live. Deferring moves byte delivery only — the action call is fully gated and audited before a token exists, exactly the presigned-URL model. Credentials are re-resolved rather than captured because storing a resolved Authorization would put a second copy of a live secret at rest outside the vault with a lifetime we don't control; re-resolving means a rotated secret is picked up and a revoked one fails closed. Multi-use rather than single-use because the motivating payload is large: single-use turns every dropped connection into an unrecoverable failure, and exposure is bounded by the TTL instead, with use_count making a leaked URL visible. Same-origin is the load-bearing constraint on the MCP path: the URL comes from the MCP server's own response and the fetch attaches that instance's credential, so without it a hostile or compromised server could name 169.254.169.254 and be handed the bearer. It costs nothing, because "the bytes are on the host you just talked to" is the actual contract.
D52: Invitations are accepted in-app by linking the pending identity to the caller's users row, keyed on their IdP-verified email
Date: 2026-08-05
Decision: GET /v1/account/invitations (plus .../{id}/accept and .../{id}/decline) answers "which orgs invited me", the invitee-side mirror of the admin-only, org-scoped /v1/org-invites. The lookup key is the caller's users.email — refreshed from IdP userinfo on every sign-in — never claims.email or identities.email, both of which an org admin writes. A row qualifies only if it is a kind='user' identity with that email, unarchived, external_id IS NULL and user_id IS NULL, and not provisioned_by = 'impersonation'; personal orgs and orgs the caller already belongs to are dropped. Accepting runs the same adopt_pending_identity primitive as the SSO callback (link user_id, bootstrap Everyone/Myself, membership::create with the invite's role, welcome email, identity.adopted audit) but does not set external_id — there is no IdP subject for that org to record — so "pending" everywhere now means external_id IS NULL AND user_id IS NULL. Accepting is refused (403 org_requires_idp_signin) for an org with allow_overslash_managed_signin = false; the invitation still lists, with a link to that org's own sign-in. Declining archives the identity with archived_reason = 'invite_declined'. Every wrong-owner request answers 404, never 403. The list is embedded in /auth/me/identity so the sidebar section costs no extra round trip.
Rationale: An invite has been a pre-created identity since migration 103, and the only thing that ever told the invitee it existed was an email linking to <slug>.<apex>/. A user already signed in on the apex had no way to discover or act on it, and the "accept" they were being sent to do was a side effect of an SSO callback rather than an operation anyone could name. The email match is the entire authorization story here, which is why it has to read the one email column the IdP asserts: identities.email is the field an admin fills in to create the invite, so trusting it would let any admin mint an invitation for an address and then read back which other orgs had invited it. user_id IS NULL joins the pending predicate because in-app accept is the first path that produces a member with no external_id — without it an accepted member would show as "pending" on the Members page forever and stay revocable from the admin's Invites card. The managed-sign-in gate exists because an org that runs its own IdP has made an admission decision that an apex Overslash session was never part of; surfacing the invitation but sending them to that IdP keeps discovery useful without spending the org's trust boundary. Decline archives rather than deletes because a pending member can already own an agent subtree — delete_identity_leaf would refuse — and archiving frees the address for a fresh invite while leaving the audit trail intact.
D53: Auto-approval is a second ceiling on the read < write < admin ladder, bounded by the access ceiling
Date: 2026-08-07
Decision: group_grants.auto_approve_reads (boolean) becomes group_grants.auto_approve_level — none | read | write | admin — carrying the same semantics access_level already has: a grant auto-approves exactly the risks its level permits, via the same AccessLevel::permits_risk. The two columns are independent settings on one ladder, with one invariant: auto_approve_level <= access_level, enforced at the API and by a DB CHECK. Raising auto-approval above the ceiling is a 400; lowering the ceiling under an existing level silently clamps the level down, because that direction only ever reduces privilege and a two-call downgrade is a worse contract than a one-call one. The risk-shaped guard in check_group_ceiling (!risk.is_mutating() && …) is gone — the level decides, and GroupCeilingResult::WithinCeiling { read_bypass } is now { auto_approved }. Defaults are unchanged: the Myself auto-grant stays access_level = admin with auto_approve_level = read, and the backfill maps auto_approve_reads = true to exactly 'read'. auto_approve_reads survives one release as a deprecated API alias (true ⇒ "read", response value derived as level != "none") and the DB column is kept in sync by the writers until it is dropped. D42's deny-sweep is widened: denied_anywhere now runs for any mutating call that took the auto-approve bypass, not only SQL-classified ones. Read bypasses keep their zero-extra-query fast path.
Rationale: The boolean pinned auto-approval to reads, so the only policy an org could express was "reads are free, everything else waits for a human". That is the right default and a bad ceiling: a team that trusts its agents to write to a scratch Jira project or an internal Slack channel had no way to say so, and the workaround — hand the identity blanket permission rules — is strictly worse, because it is invisible to the group view and outlives the grant. Putting auto-approval on the ladder access_level already uses means there is no new policy vocabulary to learn or implement: one enum, one permits_risk, and the UI is a second dropdown next to the first. Bounding it by the access ceiling is what keeps the pair honest — auto-approval is permission to skip the human, never permission to exceed what the group was granted, so the ceiling stays the single answer to "what is the worst this group can do". D42 recorded that "a write-classified call never takes the bypass, so table_mut= coverage is always walked", and a write-level grant breaks that assumption by design: table-key allow coverage is now bypassable for writes, but only where an admin explicitly opted a grant into it and never above the ceiling. What is not negotiable is deny. D42 shipped the sweep only for SQL-classified calls, which was defensible while the bypass could only free reads; once it can free mutations, a deny rule is frequently the only thing standing between an agent and a carve-out an admin made on purpose, so every auto-approved mutation now pays for the sweep. Reads were deliberately left on the old path — that behaviour predates this change, and making a previously-inert deny suddenly bind would break working agents in the name of consistency.
Date: 2026-08-11
Decision: All three Cloud Build triggers (infra/modules/cloud-build, -shortener, -metrics-exporter) build with docker buildx under the docker-container driver, exporting and importing layer cache via --cache-to/--cache-from type=registry,mode=max against a <image>/cache:buildcache ref in the same Artifact Registry repository. The dependency layer the cache exists to preserve is produced by cargo-chef: cargo chef prepare distills the workspace into a recipe.json of dependencies only, cargo chef cook compiles it, and the real cargo build follows a plain COPY . .. The hand-maintained per-crate COPY crates/*/Cargo.toml + echo "fn main(){}" stub scaffold, and the find crates/ -name "*.rs" -exec touch {} + that existed to defeat it, are gone from all three Dockerfiles. Build contexts are now whole-repo, so .dockerignore — not a COPY allow-list — decides what reaches the image.
Rationale: Kaniko was archived upstream in June 2025 ("no longer developed or maintained"), so the deploy path depended on an unmaintained builder; the exit-137 OOM on the Rust dependency layer was a symptom, and --compressed-caching=false was a workaround for one instance of it rather than a fix. BuildKit diffs layers incrementally instead of snapshotting the whole filesystem, which removes that failure class outright. Cache mounts (RUN --mount=type=cache) would remove the multi-GB layer entirely, but their contents are not exported by the registry cache exporter, and Cloud Build runs every build on a fresh VM — a cache mount there is always cold. A cargo-chef cook layer is the form of dependency cache that actually survives the trip through a registry, which is why the layer stays a layer. The stub scaffold's cost was measurable in git history: aad3d8d4, 9b3b059c, c19ccc96, eb06cb78 are four separate "copy X manifest" fixes, one per new crate, each discovered by a broken build. cargo-chef derives that list from the workspace, so adding a crate needs no Dockerfile edit.
Date: 2026-08-11
Decision: x-overslash-resolve becomes runtime-agnostic and gains a second output. The target is either get: (HTTP — an authenticated follow-up GET against the same service host, unchanged) or tool: + args: (MCP — a tools/call against the same instance, sharing mcp_caller::build_client with real dispatch so auth, SSRF pinning and host overrides cannot drift). The named tool must be risk: read, enforced at template compile. The projection is either pick: (one dot-path) or display: (a {dot.path} template reusing the description grammar, so {name}[ ({phone})] drops the bracketed segment when the phone is unknown; an empty string counts as absent, and an unresolved placeholder collapses to empty rather than leaking a literal {name}). MCP resolvers project over the tool's structuredContent, falling back to the first text block parsed as JSON. The MCP disclosure projection gains resolved, making .resolved.recipient // .arguments.recipient read exactly like the HTTP shape's .resolved.fileId // .params.fileId. Optional scope: names a dot-path whose value replaces the raw argument when deriving permission keys only — the outgoing request always carries the caller's literal arguments. Resolution stays best-effort: 3s timeout, failures dropped silently. Parsing is now lenient — a half-declared resolver lands on the action and is reported by template_validation rather than being silently dropped. Parameter-level aliases (resolve:, aliases:, instance-config:, …) now normalize inside an MCP tool's input_schema.properties, which they never did before. POST /v1/actions/validate deliberately does not canonicalize: it runs on resolve_action_metadata, which is documented as cheap — no OAuth, no upstream calls — and resolution is an authenticated round trip. The dry run therefore previews keys built from the caller's raw argument, which is the stricter reading (an address /call would collapse onto a granted canonical key previews as uncovered, never the reverse). If that divergence becomes load-bearing, the fix is to run resolvers in validate and accept that it starts touching the provider.
Rationale: The motivating approval read Send WhatsApp message "Hola Sonia, …" to 239135323373760@lid. A reviewer cannot approve that, and a privacy LID's digits are not a phone number, so no local formatting could have fixed it — the mapping has to be looked up, and the only thing that can look it up is the container holding the WhatsApp session. Resolvers already existed for exactly this job and were HTTP-only for an incidental reason: they were written as a GET against a URL, and MCP actions have no URL. Making the target polymorphic was strictly smaller than inventing a second mechanism, and it means every MCP template — telegram, slack, hubspot — inherits the capability. The read-only constraint on tool: is the one hard rule: a resolver runs before a human has seen the approval, so a resolver pointed at a write would make the act of reviewing a call perform a mutation. Resolution must be gateway-side and derived from the real argument, never a label the agent supplies, or an approval could read "to Sonia" while the message went elsewhere. scope: exists because the readability problem had a permissions twin: the same human is reachable at several opaque addresses, each minting its own key, so "Allow & Remember" for Sonia only remembered one way of addressing Sonia, and the rules list was a wall of JIDs. Canonicalizing onto the phone number makes a grant both stable across addresses and legible. It is safe in the direction that matters — a failed lookup yields no canonical value, so the key stays the raw argument, matches no grant, and gates — and it spends no new trust, since the value comes from the same upstream the call is about to act on. It is nonetheless a live behaviour change: a grant already stored against a raw @lid stops matching and re-prompts once. Parse leniency changed because the old silent drop turned a typo'd resolve: block into a no-op whose only symptom was an approval still quoting a raw ID — the linter should name that, not swallow it. The alias walk was the same class of bug found while wiring this: resolve: sat next to an already-unprefixed risk: in services/whatsapp.yaml and did nothing, because alias normalization stopped at the tool object and never descended into input_schema.
D56: Call timeouts resolve through a five-layer cascade clamped by two ceilings; streaming bounds time-to-first-byte, not the transfer
Date: 2026-08-11
Decision: POST /v1/actions/call takes timeout_ms, and how long a call may wait upstream resolves most-specific-first through per-call → action template (x-overslash-timeout_ms) → service template (info.x-overslash-default_timeout_ms) → org (orgs.call_timeout_ms) → deployment (CALL_TIMEOUT_MS, default 30000). The result is clamped by orgs.max_call_timeout_ms and CALL_TIMEOUT_MAX_MS (default 110000); caps combine by tightest wins rather than by specificity. A caller-supplied value above the effective maximum is a 400 naming the ceiling, while a template or org default above it is silently clamped with a warning. Exceeding the budget is a 504 carrying timeout_source — the layer that set it — plus an action.executed audit row with detail.error.kind = "timeout". Resolution happens once, before the MCP/HTTP/deferred forks, in a pure services::call_timeout::resolve; a gated call stores its resolved budget on the approval, and replay re-clamps that stored number against the org's current maximum rather than re-running the cascade. Org policy is two nullable columns, not one, exposed on the existing /v1/orgs/{id}/execution-settings. EXECUTION_REPLAY_TIMEOUT_SECS stops being a latency policy and becomes a derived outer wall (Config::replay_wall_clock), always at least CALL_TIMEOUT_MAX_MS plus slack, from which the orphan-reaper grace is also derived. Streaming is bounded differently: for prefer_stream: true the resolved timeout bounds only time-to-first-byte, and the transfer is bounded by a per-chunk idle timeout (CALL_STREAM_IDLE_TIMEOUT_MS). ActionPatch gains timeout_ms — the first non-restrictive field on that struct.
Rationale: Before this there was one global env var, it applied only to approval replays, and the inline call path had no timeout in code at all — it simply rode until Cloud Run cut the connection at 120s, handing the caller an opaque proxy 504 with no audit row. An org whose Metabase aggregations legitimately take 90s could not say so, and an org that wanted to bound its agents could not say that either. Five layers rather than a flag because the knowledge is genuinely distributed: the deployment knows its own request cap, the org knows its tolerance, the template author knows the upstream, and only the caller knows this particular query. Two org columns rather than one because a single column cannot be both a default and a ceiling — as a default only, a per-call override escapes governance; as a ceiling only, every call runs at the maximum with no sane middle. Templates set defaults and never caps, because a template encodes knowledge while a cap is policy, and user- and org-authored templates exist: letting a template author set a cap would let them bind their own admin. The 400-vs-clamp asymmetry is the same argument from the other side — the caller is present and can act on an error, whereas a mistyped template value that 400s every call in the org is strictly worse than one that quietly runs at the ceiling. timeout_source on the 504 is the field that pays for itself: without it, "why did this time out at 30s when the org default is 90s" is an afternoon of grepping. Exceeding CALL_TIMEOUT_MAX_MS errors rather than silently promoting the call to a background execution, because auto-promotion would change the response shape based on a number in a template the caller never saw — exactly the class of surprise that breaks agents.
The streaming split is the load-bearing implementation decision. reqwest's RequestBuilder::timeout is a total deadline that covers the response body, and read_timeout is ClientBuilder-only in 0.13 — so the obvious implementation (one .timeout() on both paths) would abort a streamed transfer mid-body, after write_stream_audit had already recorded a 200 and after axum flushed the headers. The client would receive a silently truncated body while the audit trail claimed success, and a legitimate 900MB export would fail at exactly the resolved timeout no matter how healthy the transfer was. Splitting the phases — a tokio timeout on the header phase, a per-chunk idle guard on the body — is what makes the resolved timeout mean the same thing to a caller on both paths ("how long before you give up on this upstream") without redefining slowness as failure. Replay re-clamps rather than re-resolving because the stored payload has the request but not the action key the template rungs were read from; re-clamping is the half that can be done, and it is the half that matters, since it is what stops a stale approval from outranking a tightened org policy. ActionPatch::timeout_ms breaks that struct's "restrictive, monotonic" contract knowingly: a timeout grants no capability the caller did not already have, and both maxima still clamp it — the alternative was forcing an org with a slow self-hosted upstream to re-author the entire action through Extensions to move one number.
D57: An oversized response returns the 502 with a capability URL already minted, and action parameter contracts reach the model
Date: 2026-08-11
Decision: When a buffered HTTP call exceeds max_response_body_bytes, the gateway mints a deliver: "url" capability token for the same request at the point of failure and returns it on the 502 as download_url + expires_at, with a hint that leads with narrowing the call. It stays a 502 — nothing silently succeeds, and a caller that ignores the new fields sees exactly the pre-D57 error. Minting is best-effort: the existing refusals (OAuth-injected services, raw HTTP carrying inline credential headers) and any other mint failure collapse to None, and the hint falls back to the transport-aware wording introduced alongside this (deliver: "url", plus prefer_stream: true only where it is reachable). The hint therefore has three forms under one rule: never name a recovery the caller cannot use — a minted URL supersedes both flags, since telling a caller to retry with deliver: "url" when its URL is already in the body is the same wasted round trip that naming prefer_stream over MCP was. The action.deferred audit row records cause: "response_too_large" to distinguish it from a caller that asked. Separately, SearchResult action rows now carry params — a lean model-facing projection of the action's parameter contract (name, type, required, description clamped to 160 chars, enum, default), required-first then alphabetical, with instance-config params excluded. And filter is declared on the overslash_call / overslash_read MCP tool schemas and forwarded by the dispatcher, lifting a bare jq string into the wire's {lang, expr}; it now also applies on the MCP-runtime and platform-runtime forks, which previously accepted it, validated its syntax, and silently ignored it.
Rationale: An agent asked "which Metabase cards are popular", found only run_card (one card by id) and list_cards (every card in the instance), picked the latter, pulled 31 MB / 2,033 cards, blew the cap, and had to re-issue with deliver: "url". Three things had to be true at once for that to happen, and the fix addresses all three.
The obvious remedy — "it should have passed a filter" — does not work and is not a documentation gap. The cap is enforced inside http_caller::call while the body is still arriving; the filter is post-processing of an already-buffered body, so on an oversized response it never runs. That ordering is deliberate (a filter cannot un-send bytes the upstream already sent) and is pinned by test_filter_does_not_rescue_oversized_upstream. Given that, the only two available fixes are making the retry cheaper and making the narrower call visible in the first place.
Minting into the error is nearly free because deliver: "url" on the HTTP runtime never needs the body: it persists the credential-free ActionRequest and replays it at redemption. So the failure already holds everything the retry needs, and asking the caller to construct that second round trip was asking it to re-derive what we knew. Keeping the 502 rather than transparently returning a 200-with-URL is the load-bearing half: an agent that ignores an unfamiliar field must not read a failed call as a successful one with no data.
Parameter contracts reaching the model is the actual root cause. ServiceAction.description was documented as "the only string about an action that ever reaches the model — parameter descriptions, defaults, and response schemas do not", which made every declared paging parameter folklore: it existed, it worked, and nothing advertised it. A template author's only recourse was to restate the whole contract in prose. The projection is deliberately not ActionParam itself — resolve, sql_field, sql_database and instance_config are gateway plumbing the caller neither supplies nor benefits from seeing, and instance-config params are actively harmful to list, since an org admin pins them and a caller supplying one can only get it wrong. Ordering is explicit because params is a HashMap: without a sort, byte-identical requests return differently-ordered JSON.
filter over MCP closes the last gap. Both tool schemas are additionalProperties: false, so an undeclared property is a hard reject — an MCP agent could not pass a filter at all, which is to say the one server-side lever for shrinking a response before it enters a context was reachable only from the CLI, the SDK and raw HTTP. Applying it on the other two runtimes is a correctness fix rather than a feature: they have no upstream size cap to dodge, but they have the same context budget, and a caller could not distinguish a filter that matched nothing from one that never ran.
D58: The Live Map rides a new activity topic whose per-call events are emitted only behind a flag, from the one wrapper that already classifies the outcome
Date: 2026-08-11
Decision: GET /v1/events/stream gains a fourth topic, activity, carrying action.called and action.completed. Both are emitted from routes/actions/mod.rs::call_action — the wrapper that already brackets the request for overslash_action_executions_total — and not from the four terminal sites inside call_action_impl (MCP ok, MCP transport error, HTTP ok, HTTP transport error). The pair shares a call_id minted in that wrapper, and action.completed reports the wrapper's existing status_label (called | denied | rejected | failed | upstream_error) plus duration_ms. Emission is gated on config.live_map_enabled (OVERSLASH_LIVE_MAP), reported to clients as live_map on GET /v1/version, and set on dev and in scripts/e2e-up.sh — never in production. The topic string is always valid regardless of the flag. Audience is chain(actor) (audience::for_action), resolved once before the call and reused by both events; org admins bypass it through the existing delivery predicate. The dashboard subscribes to approvals,activity unconditionally and renders the graph at /map, whose nav item is gated on live_map.
Rationale: The Live Map's subject is per-call traffic, and the stream had no per-call event: its three topics fire when something is gated or reconfigured, and the overwhelming majority of calls are auto-allowed, so a map built on them would have been almost entirely still. Adding the events was therefore the feature, not an optimisation of it. Gating them is not caution about correctness but about volume: these are the first events on the gateway's hottest path, one durable events row each, where every other event in the system is emitted per operator action. The flag is what lets that cost be opt-in per deployment rather than a tax on every org, and /v1/version reports it for the same reason sql_policy is reported there — a page that renders a permanently motionless graph is worse than a page that is honestly absent. Emission sits in the metrics wrapper because that function already owns the outcome taxonomy, including the UpstreamErrored marker that distinguishes an upstream's 500 riding behind an outer 200 from Overslash's own failure; duplicating those rules across four call sites to gain a slightly earlier service resolution would have been a bad trade, and the wrapper's service/action strings are what the map matches against listServices() anyway. The pair is deliberately not ordered: the two events bracket the upstream call, so emit_all — which exists precisely to keep a derived event behind its cause — cannot span them, and each emit spawns its own task. That is why call_id is minted rather than inferred from arrival order, and why the client treats a completed for an unknown call_id as a packet already on its return leg instead of dropping the call. The audience is the actor's chain and nothing wider: an org-wide fan-out of every call would be the broadest disclosure surface in the system, whereas the chain rule discloses exactly what GET /v1/audit already shows the same caller, and lets a parent keep watching its sub-agents. Admins bypassing it is what makes one stream serve both an operator's org-wide view and a member's personal one, with no second ACL.
Date: 2026-08-11
Decision: audit_log gains actor_name and owner_user_name (migration 110), filled at INSERT time by a CTE inside the existing statement — no second round trip on a path that runs at every mutation. owner_user_name is the actor's owner_id — which is a flattened pointer to the root user, maintained for every descendant on create and on move, so a sub_agent at any depth already resolves to the human at the top — falling back to the actor's own name when the actor is a user, whose owner_id is NULL. This is exactly what the join it replaces computed, at every depth: user = and user ~ semantics are unchanged. Both columns are NULL when there is no actor. The dashboard's free-text q, identity ~ and user ~ all match these columns, and the table renders the recorded name — marked with a dotted underline when it differs from the identity's current name, with the live name in the hover and a "Recorded as" line in the expanded pane. The SPIFFE identity_path stays live and id-keyed, so the current chain is always one hover away, and identity = <name> still resolves through an id, so filtering by actor is unaffected by renames. query_filtered consequently drops both identities joins; kind and ownership move into EXISTS subqueries that cost nothing when those filters are absent. The free-text clause keeps its exact NOT EXISTS semantics and gains one redundant, indexable pruning conjunct over the longest term of three characters or more, which a pg_trgm GIN index on action || description || actor_name serves.
Rationale: A stale name is not the compromise here, it is the point: an audit row should say who acted under the name they had when they acted, and reading the name through a live join silently rewrites history every time someone is renamed. The deleted-identity case makes it sharper — identity_id is ON DELETE SET NULL, so before this change deleting an identity erased the actor from every row it had ever written, and the log lost exactly the name a reader needs most. What forced the change now was performance, and the two motivations point the same way. q matched a joined column, and a free-text predicate spanning two tables forecloses every indexing strategy on audit_log: the planner will not use a trigram index for an OR that reaches outside the table, so a term matching nothing walked the org's entire history — 2487 ms on 400k rows, of which ~1.7 s was the per-row identity join alone. Materializing the name is what makes the predicate local, and therefore indexable at all. The pruning conjunct exists because #533's multi-term form is a correlated NOT EXISTS over unnest, which is an anti-join subplan the planner cannot turn into an index scan; a single-parameter conjunct gives it something to prune with, and it is sound because it is a superset of the real predicate — a row matching any one column matches the concatenation, so it can only admit extra rows, which the NOT EXISTS then rejects. Search and display had to move together: if search matched the historical name while the table showed the current one, an operator searching for what is on their screen would get nothing, which is the worst of both semantics. The divergence that remains is deliberate and narrow — the User column keeps labelling by live email, because it shows an email, not a display name, and resolves it by id.
D60: An instance that was never configured fails as needs_authentication naming the fields, not as an unauthenticated request
Date: 2026-08-11
Decision: When credential resolution produces nothing at all — no OAuth header, no secret — and the template declares a credential the instance never supplied, POST /v1/actions/call returns 401 needs_authentication instead of dialling upstream. resolve_instance_auth's instance_secret_missing boolean becomes a MissingCredentials { slots, config } carried out on ResolvedAuth, so the gate names the exact fields rather than re-deriving the resolution chain. The existing needs_authentication code is reused rather than a new one minted, gaining two fields: missing_credentials (slot keys and required config vars that resolved to nothing) and hint_url (a dashboard deep-link — /services/{id}?tab=credentials with an instance, /services/new?template={key} without one). The secret-backed shape carries no auth_url/provider: there is no consent page to send anyone to. Both fields render in the headless branch too, minus the link. The gate runs in both call shapes; the OAuth gate's absence from the verb shape is a separate, pre-existing gap and stays. It does not fire when the template requires no credential the instance is missing — deepwiki, the platform runtime, and an optional-only credential such as email's gateway key on a keyless overfwd all still send exactly as before. Affected shipped templates: email, stripe, resend, metabase, test_email, plus every org/user template declaring a secret scheme. MCP-bearer templates (telegram, whatsapp) are untouched — they fork earlier and already error rather than sending unauthenticated. The gate lives in resolve_request, so like its OAuth twin it runs before the permission gate: an unconfigured instance answers needs_authentication even where the caller would have been denied or sent to approval.
Rationale: resolve_instance_auth has refused to emit a partial credential set since it was written, and D38 extended that refusal to a required config var with no value — correct, and covered by tests: nothing truncated is ever sent. What it did afterwards was the defect. It fell through to resolve_service_auth, which knows only OAuth and the env-backed OAuth client cascade, so for a secret-backed template it resolved nothing and the call went out with an empty credential set. The gateway therefore knew precisely what was wrong — "this instance has no mailbox_user" — and told the caller something else: whatever a real overfwd says to an unauthenticated request. That is the same failure shape as the Metabase field report, and it was already an internal contradiction, since derive_credentials_status classifies exactly this state as NeedsAuthentication for the dashboard badge while the call path shipped the request anyway. Reusing the needs_authentication code is what makes the fix free for consumers: it is already on the MCP relay's typed-error whitelist, already mirrored by ?wrap=true, and already the thing every agent branches on — a new code would have needed all three taught about it, and would have split one question ("what do I have to do before this call works?") across two vocabularies. The cost is that auth_url is no longer implied by the code, which is why the variant's doc comment now states it and why the field list ships alongside: an agent that reads missing_credentials can tell the user what to fill in even where no link exists, which is exactly the headless case. Deriving the missing keys from the resolver rather than recomputing them in the envelope builder is deliberate — the chain (per-slot binding → legacy scalar secret_name → org default → platform credential, plus the config pass) is intricate enough that a second implementation would drift, and the drift would be silent: a wrong field name in an error message, not a failing call. The builder keeps a derivation only for the no-instance path, where there is no resolution attempt to report. The behaviour change D38 deferred is now taken deliberately, and it is genuinely a change: any secret-backed template that previously produced an upstream 401 now produces ours. That is the point — the upstream 401 was never actionable, and this one names the field and links the form. Running before the permission gate is inherited rather than chosen: the OAuth gate has sat there since it was written, so "deny beats every allow mechanism" (D42) has always been conditional on credentials resolving at all, and this changes the size of that pre-existing window rather than opening a new one. It leaks nothing the caller did not already supply — they named the service and resolved the instance to get here, and hint_url is a dashboard path, strictly less than the minted flow URL the OAuth shape already hands back on the same path. Two D53 tests had to bind a credential they had left unset as scaffolding, which is the honest signal of the change: an unconfigured instance now stops earlier than it used to.
Date: 2026-08-11
Decision: When a verbose: false render actually truncates, the full ActionResult is serialized, encrypted with the existing AES-256-GCM keyring, and written to a new call_results table; a download_tokens row is minted pointing at it, and the descriptor is stamped into the same envelope as the cropped body under _full_result: {download_url, expires_at}. Redemption reuses GET /v1/downloads/{token} unchanged — per-IP throttle, hash claim, identity re-check, action.downloaded audit — with one branch: a row carrying call_result_id serves stored bytes instead of replaying request. Migration 111 makes download_tokens.request nullable and adds CHECK (num_nonnulls(request, call_result_id) = 1), so a token names exactly one source of bytes. Storage is bounded by CALL_RESULT_MAX_BYTES (default 1 MB, 0 disables) and shares DOWNLOAD_TOKEN_TTL_SECS (900s) — one TTL knob, one sweeper step (call_result_expiry), with the token's expiry clamped to the result's so the stated expires_at is true rather than optimistic. Storing is best-effort: every failure path returns None and the envelope falls back to the pre-D57 hint, because the call it belongs to has already succeeded. The _hint gains a second form. D57 already rewrote the cropped-response hint to lead with narrowing (paging params, then a filter); the stored variant keeps that ordering and only swaps the fallback, since a stored copy is free where verbose=true pays for the upstream call again. Nothing is stored for verbose renders, for compact renders that fit, or for deliver: "url" calls.
Rationale: This is the rendering twin of D57. D57 covers the call that never produced a usable body — the transport cap tripped, nothing was buffered, so the only thing that can be handed back is a token to replay the request. This covers the opposite case: the call succeeded, the body was buffered, and it was the 8 KB compact render that dropped it. The motivating field report has an agent driving Metabase receive 10 of 254 rows inline, then re-run a 30-second query purely to change the delivery mode of bytes the gateway had in hand and threw away. The hint it was handed made this worse rather than better: "pass verbose=true to see the full response" reads as a cheap toggle, but verbose is a field on a new CallRequest, so acting on it pays for the upstream call a second time — and deliver: "url" on the HTTP runtime mints before calling (D51), so that is a third. Every documented recovery from a crop was a re-execution. The store is deliberately narrow: only a caller who asked for compact and got less than the upstream sent has anything to re-fetch, so a verbose dashboard call and an under-budget compact call both write nothing — storing unconditionally would make Overslash a full copy of every upstream response at rest, which the audit path already refused (org-gated, 64 KB cap, off by default). Over the size cap we store nothing and say why, because a silently shortened "full result" is strictly worse than none: the agent would fetch it and believe it complete.
A separate table rather than a body column on download_tokens because that module's whole documented invariant is "the row keeps a credential-free ActionRequest and replays it"; a row that replays nothing would leave request, credential_ref and service_instance_id as dead weight and make redemption bimodal in the one dimension its docs are about. Not executions either — approval_id is NOT NULL with an FK to approvals, and services::inbox defines result_unread off that table, so every inline call would become a permanent unread item in get_events. The blob is encrypted because we do not choose the contents: an upstream is free to return a refresh token in a JSON field, and the response headers — Set-Cookie, an echoed Authorization — are serialized into the same blob, so plaintext bodies would re-open from the response side exactly the hole D51 closed from the request side. The consequence is accepted knowingly: the column is BYTEA, so nothing can query inside a stored result, and audit capture remains the consented path for that. call_results.body_ciphertext joins key_rotation::TARGETS in the same change, since an encrypted column that never rotates looks safe while silently pinning one key forever.
Access is the bearer-token model rather than a new authenticated re-read endpoint, and that choice is what keeps this small: no new REST surface, no new MCP action, and therefore no question about whether an agent re-reading its own output should raise an approval. It also closes a gap D51 left and D57 had to work around — deliver: "url" refuses OAuth-authenticated services because a deferred fetch cannot re-mint an OAuth bearer, which is one of the refusals D57's best-effort mint collapses to None on. A result-backed token dials nothing, so on this path every Gmail / Drive / Calendar truncated result gains file delivery it has never had. content-length is dropped from the forwarded-header allowlist on this path alone: the stored body went through String::from_utf8_lossy, so the upstream's byte count can disagree with what is actually written, and a mismatched length is a framing error rather than a cosmetic one. The upstream status is likewise not replayed onto the redemption response — the stored body may be a 404 the agent asked to look at again, and a curl seeing 404 would write nothing and report a failure that did not happen.
This does not fix the truncation itself. Compaction is still uniform across the JSON tree, so a Metabase-shaped payload still spends its 8 KB budget on cols and results_metadata before reaching rows, and the agent still sees 10 of 254 inline. What changed is the price of the recovery: a curl instead of a re-query. D57's two levers — advertised paging params and a reachable filter — attack the same problem from the request side, and between them the cropped-response hint can now name three genuinely cheaper moves. Making truncation itself priority-aware is tracked in TODO.md §3.
D62: Async calls are a leased row the caller polls, not a detached task, and execution: "async" never changes a gated call's response shape
Date: 2026-08-11
Decision: POST /v1/actions/call takes execution: "sync" | "async". An async call is accepted with 202 {"status": "accepted"} carrying an execution_id, and is dialled off the request path by a claim-and-lease worker; the caller polls GET /v1/executions/{id}, subscribes to the new executions SSE topic, or reads it in the dashboard. The row lives in executions, extended rather than duplicated: approval_id becomes nullable and request IS NOT NULL marks a row as worker-run, deliberately orthogonal to approval_id so that "gated call, approved, then run async" is a legal third shape. The field is named execution and not async because async is a Rust keyword and reserved in JS/TS. The fork sits after the entire validation and authorisation pipeline and before every dispatch fork, so an async call inherits aliases, instance config, coercion, validate_args, the D42 SQL policy, owner impersonation, the group ceiling, the deny screen, the chain walk, and D56 timeout resolution unchanged — and cannot dodge any of them. A gated async call therefore returns the ordinary pending_approval envelope, not a 202. Routing that approval's eventual replay through the worker was not in the first cut — a gated call ran synchronously when approved, so execution: "async" was accepted and then forgotten at the gate, with approvals.execution_mode shipping reserved and read by nothing. That gap was the largest thing this decision left undone, and it was deliberate scope rather than an oversight discovered late — the direct path is what makes the D56 ceiling escapable at all, and the gated path followed without changing anything decided here. D66 closes it. Concurrency is a FOR UPDATE SKIP LOCKED claim under a renewable lease with a heartbeat that doubles as the cancel poll; attempts counts only attempts that lost a lease, and is incremented by the reclaim sweep rather than by the claim. Cancellation is cooperative: it stops Overslash waiting, it does not stop the upstream. prefer_stream, deliver: "url", return_url, platform-runtime actions, and response_type: binary are each a 400 in combination with async; filter and verbose are allowed. The deployment ceiling is per-mode — ASYNC_CALL_TIMEOUT_MAX_MS (default 900000) replaces CALL_TIMEOUT_MAX_MS in the same call_timeout::resolve call, with no change to the resolver.
Rationale: D56 capped the synchronous ceiling at 110s because Cloud Run cuts every request at 120s, and explicitly refused to auto-promote an over-ceiling call to a background one — that would change the response shape based on a number in a template the caller never saw. This is the surface that 400 points at, and the shape of it follows from taking that refusal seriously: promotion has to be something the caller asks for, in the request, so the response shape is always predictable from the request alone. That is also why a gated async call returns pending_approval rather than 202 — the caller asked for async, but the gate is a different axis, and collapsing the two would mean an agent could not tell "queued" from "waiting on a human" without a second field.
The load-bearing constraint is not CPU, and it is worth recording because the obvious reading of infra/modules/cloud-run/main.tf gets it backwards. cpu_idle is absent there, which looks like the API is on the throttled default; in fact the provider only defaults it to true when the resources block is absent, so this service has always had always-allocated CPU (the sibling shortener and overfwd modules set cpu_idle = true explicitly to opt into throttling and earn the 256Mi floor). Background work on the API instance therefore already works, and the maintenance loop, emit_all, and spawn_auto_call were never throttled. What does bind is scale-in: Cloud Run's autoscaler is request-driven, a queued row generates no scale-out pressure, and SIGTERM gives ~10s before SIGKILL. Instance death is thus the normal case, not the exceptional one — which is precisely why a detached tokio::spawn is not an option and the work has to be a durable row under a lease, so a killed job is late rather than lost. It is also why dev moves to min_instances = 1: at 0 there is simply no process to drain the queue.
Extending executions rather than adding a sibling table buys the six-state CHECK, the expiry sweep, ExecutionSummary, result_viewed_at, tags, MCP get_result and CLI get-result for free; the cost is one partial unique index and a discriminator. Making that discriminator request IS NOT NULL rather than an origin enum keeps the two axes independent, which is what lets the new lease sweeps say AND request IS NOT NULL while the pre-existing orphan sweep says AND request IS NULL — so neither can ever reach the other's rows, and the old sweep's semantics are unchanged rather than merely untouched. attempts counts lost leases rather than claims because that makes a clean hand-back at shutdown free: the worker releases its lease on SIGTERM and nothing has to decrement. It defaults to 1 because an action call is not idempotent and there is no idempotency-key concept — a POST that already reached the upstream must not be replayed because a worker died.
The per-mode ceiling needs no new resolver because call_timeout::resolve already takes global_max_ms as a parameter; async simply passes a different one. The 110s number exists because a proxy is counting, and for an async call no proxy is — so reusing it would have been cargo-culting a constraint that does not apply. orgs.max_call_timeout_ms still clamps async, which means an org that set 60000 to bound how long its agents hold connections has also bounded its async jobs; that is knowingly kept, because governance that only binds the cheap path is not governance, and no org is surprised by a call running shorter than they authorised.
D63: A service icon is one authored string with two forms, implicit from the template key, resolved server-side to an absolute icon_url
Date: 2026-08-12
Decision: info.x-overslash-icon (alias icon:) accepts either builtin:<name> — an asset Overslash ships and serves itself — or an https:// URL hosted elsewhere. It is normally absent: a template whose key matches a shipped asset resolves to builtin:<key> implicitly, so 18 of the 23 shipped templates declare nothing and github_legacy_oauth is the only one that needs an explicit value (its key deliberately differs from the github mark it reuses). The implicit lookup runs inside compile_service, not at response time, because a derived layer resolves through apply_delta under its own key: a layer named acme_github has no acme_github.svg, so a later lookup would find nothing and silently demote it to a monogram, where resolving at compile makes the existing delta.icon.or(base.icon) fold inherit the base's mark for free. Delta gains icon alongside display_name/description, and validate_delta runs the same check the standalone path runs — without that, the derived-layer write path is an unvalidated back door into the https-only rule. Parsing is permissive (classify, reject only empty/oversize/control characters) and policy is separate: template_validation rejects any non-https remote as an invalid_icon error, and resolve_icon_url re-checks the scheme when the response is built, so a value stored before the rule existed still never reaches a browser. A malformed icon is a compile warning, not an error, because ServiceRegistry::load_from_dir skips a whole file that fails to compile and losing a service over a typo'd logo is strictly the worse failure — the same reasoning hidden and default_timeout_ms already carry. Assets are generated from the pinned simple-icons package into assets/service-icons/ by make service-icons (committed-but-generated, like SCHEMA.sql and the sqlx cache), named by template key rather than upstream slug, and baked into the API binary with include_bytes!. They are served at GET /icons/{key}.svg from global_routes — outside auth and outside rate limiting, because an <img> sends no Authorization header and cross-origin sends no cookie — with nosniff, a sandboxing CSP, and a day of Cache-Control plus a strong ETag. The wire field is icon_url, absolute, on TemplateSummary/TemplateDetail/AdminTemplateSummary and ServiceInstanceSummary/Detail. No migration: the value already persists inside the existing openapi and delta jsonb.
Rationale: Deriving an icon from the template's own servers[] favicon is the obvious idea and it is wrong here. The shipped hosts are api.github.com, api.stripe.com, www.googleapis.com — which is shared by Drive and Calendar, so two distinct services would collide on one mark — gmail.googleapis.com and friends, which serve no favicon at all, and ${METABASE_URL?}, which points at a customer's internal host. Beyond the collisions, a favicon tier fires a third-party request per service row on every catalog render, from every operator's browser. There is no favicon tier at any priority.
Hosting is the second thing that looks like a shortcut and isn't. A CDN does not solve the trademark question: simple-icons' own disclaimer states that the project's CC0 covers the path data and not the marks, and asks users to seek their own permissions — Slack, LinkedIn, Eventbrite and Microsoft have no entry at all, several because the brand asked to be removed. What a shared icon set does buy is provenance and a consistent glyph set instead of hand-downloaded files of unknown origin. Given that, bundling beats hotlinking on every axis that remains: the dashboard currently loads zero third-party hosts at runtime and @fontsource-variable/inter already set the precedent of vendoring rather than hotlinking Google Fonts; bundling keeps that posture, works air-gapped, and needs no img-src if a CSP ever lands. The four brands with no mark ship none and render the monogram — which is exactly what every service rendered before this change, so nothing regresses.
Naming assets by template key rather than upstream slug is what makes the convention possible at all, and it is why google_calendar.svg is generated from simple-icons' googlecalendar. The alternative — an explicit icon: on all 23 templates — is 23 lines that restate the key, 23 chances to typo, and a silent monogram when someone renames an asset. The registry test asserts every shipped template resolves to some icon, with the four pending ones listed by name, so a rename fails CI instead of quietly degrading.
No denormalized column, though display_name, description, category and hosts all have one. Those exist so the catalog needn't compile every row, but every read path that renders an icon already runs the full fold — it must, because a derived layer's effective name lives in its delta rather than its column. A sixth column would buy marginally better degraded rendering in exchange for a write-path invariant to keep in sync across create, update and the derived-layer branch that already knowingly passes hosts: &[]. And the degraded case wants the fallback: "the base template is broken" is a state where a letter tile is more honest than a stale mark.
/v1/search deliberately does not carry icon_url. It fans out up to 100 rows per (instance × action) behind MCP overslash_search, and every field is paid for in the caller's context window for something no model can render — the same reasoning D57 applies to action descriptions. The overslash meta-service's list_services does return it, because it hands back ServiceInstanceSummary through the shared kernel; that is roughly one row per instance rather than a hundred, and those actions are what a dashboard-like client calls. Accepted knowingly rather than special-cased.
Instances get no icon of their own. An instance is a binding of a template to a credential — two Gmail instances are both Gmail — and a per-instance icon would be the first place a user-supplied image URL reached other users' browsers, a materially worse surface than an admin-authored template. The URL is absolute rather than relative because the same JSON is read by the dashboard on a different origin in cloud (app. vs api.), the CLI and the SDK; a relative path would resolve against whichever origin served the page and 404. Absolute does not mean self-sufficient, though, and the first cut of this claimed it did: on cloud public_url is the app origin, not the API's, so every icon_url lands on the dashboard host — which serves only what vercel.json explicitly proxies. Without a rewrite every icon 404s to text/html and degrades to a letter tile, silently, because the fallback is doing its job. /icons/:path* is proxied alongside /health in all four blocks, and a test asserts that pairing so the next browser-facing route added outside auth cannot repeat it.
D64: Display-param resolvers are cached on a bounded-staleness window, Valkey-backed with a process-local fallback
Date: 2026-08-12
Decision: An x-overslash-resolve answer — both halves, the display string and the scope-derived canonical value — is cached and reused. The store is REDIS_URL-backed when one is configured and a DashMap otherwise, chosen once at boot by services::resolve_cache::create_resolve_cache, mirroring rate_limit::create_store_with_eviction; a Redis error at request time is a miss, never a fall-through to the in-memory map, so a hit can never depend on which of two stores answered. Every operation is bounded by RESOLVE_CACHE_TIMEOUT_MS (100ms) and fails open — the call resolves live. All of an action's resolvers are read in one pipelined round trip (single-key GETs, not MGET, which is cross-slot and would hard-error on a clustered Valkey) and written back in one pipeline of SET .. EX, because the TTL is per-resolver. The lookup is a two-phase plan() executed by the caller before it assembles credentials: on HTTP a full hit skips the vault decrypt that builds resolver headers, on MCP it skips build_client entirely — the vault reads plus the blocking to_socket_addrs in ssrf_guard::build_pinned_client. Keys are osr:v1:{namespace}:{org_id}:{sha256} over a length-prefixed preimage of (org, ceiling user, instance, credential fingerprint, service key, runtime, target, display template, scope path); the org id is the only plaintext component. Values are JSON, encrypted with the existing keyring, and carry a neg marker distinguishing "the resolver ran and projected to nothing" (success TTL) from "the resolver did not answer" (RESOLVE_CACHE_NEGATIVE_TTL_SECS, 30s). TTL resolves most-specific-first — the resolver's cache_ttl:, else RESOLVE_CACHE_TTL_SECS (300s) — then clamps to RESOLVE_CACHE_SCOPE_TTL_MAX_SECS when the resolver declares scope:. cache_ttl: 0 skips the read as well as the write. Failures that are ours rather than the provider's — a credential that would not build, an MCP client that could not be constructed — are never negatively cached. POST /v1/actions/validate still does not resolve.
Rationale: services/gmail.yaml asks GET /gmail/v1/users/me/profile on nineteen actions to turn me into an address that only changes when the connection does, and resolve_request runs on every /v1/actions/call, including auto-approved reads that never gate. An agent listing messages twenty times paid twenty identical round trips, and a provider that was merely down cost every call the full 3s resolver timeout on top of its own latency.
The load-bearing consequence, and the reason this is written down rather than treated as an implementation detail: a cached resolver answer is an authorization decision, not just a latency optimisation. scope: canonicalizes the permission key while the outgoing request keeps the caller's raw argument (D55). Live, those two are consistent by construction — the canonical value is derived microseconds before the key is built. Cached, they are not: the key reflects the mapping as of up to a TTL ago and the call targets the mapping as of now. If a WhatsApp JID is re-pointed inside the window, a grant minted for the old person still matches, and the message goes to the new one with no human in the loop. That is precisely the failure D55 exists to prevent ("an approval could read 'to Sonia' while the message went elsewhere"), reintroduced on a timer. It is accepted, bounded, and paid for three ways: a short default, a separate and tighter ceiling for scope-bearing resolvers, and a resolver_cache_ttl_wide warning so an author widening the window is told what they are widening. The knob is on the template because only the author knows whether a mapping is immutable — me → your own address is safe for an hour, a JID → a phone number is something the provider can re-point under you. Defaulting long and asking WhatsApp to opt down would get the incentive backwards: whoever forgets the knob must land in the safe place. Everything else about a miss fails closed — no canonical value means the key stays the raw argument, matches no grant, and gates.
The credential fingerprint is the field that makes the cache safe to share at all. gmail's userId: me produces a byte-identical URL for every user alive, so a key without it would serve one person's email address into another person's approval and into their permission key. It is never the credential: a bearer rotates hourly, which would miss on exactly the long-lived lookups worth caching, and an unsalted hash of a live token sitting in a shared store is an offline confirmation oracle. OAuth contributes the connection id (threaded out of resolve_effective_mcp for this, which also fixed MCP calls never recording the account they authenticated as); secret-backed schemes contribute vault references, which also covers a case the connection id misses entirely — a Mode B/C call passing explicit req.secrets has no connection and no principal, so two agents under one owner using different secret names would otherwise share one entry. The preimage is length-prefixed rather than delimiter-joined because service_key is org-authored and a field containing the delimiter could otherwise shift the boundaries.
Values are encrypted because of what they are: names, phone numbers, email addresses, file titles. Process-local that is unremarkable; in Valkey it is a new data-at-rest surface, and infra/modules/memorystore/main.tf sets neither auth_enabled nor transit_encryption_mode, on an instance infra/main.tf shares with the public URL shortener. Encryption is the half that ships with the code and needs no operator action; turning on AUTH and TLS is a separate infra task, breaking for oversla-sh too. It costs microseconds, and rotation invalidating the cache is harmless since entries are ephemeral by construction — but it does extend the master key to a second purpose, which is the one thing here worth revisiting if the keyring's blast radius ever becomes a concern.
Two smaller choices. The in-memory backend, on overflow, evicts a bounded arbitrary sample and inserts, rather than declining the write: declining freezes the map on whatever arrived first, so a hot key could never be re-admitted after expiring while a flood of one-shot arguments squatted — the opposite of what a cache is for. And local failures are excluded from the negative cache because caching them turns a transient misconfiguration into a sticky one on every replica; a provider 404 or timeout, by contrast, is a real answer and is exactly where a negative entry earns the most.
Not done: single-flight. Concurrent identical misses still both fire, which is today's behaviour — the thundering herd is now cross-replica but no larger. And validate still does not resolve, because consulting the cache there would make a dry run's answer depend on cache state and reopen the asymmetry D55 chose deliberately.
Date: 2026-08-12
Decision: services/disclosure.rs collapses every per-filter failure to a fixed classification — filter runtime error, optionally qualified (cannot index) / (cannot calculate) / (cannot use) — chosen by a classify(&str) -> &'static str whitelist over jaq's own fixed error prefixes. The return type is the guarantee: nothing operand-derived can escape by construction, even when the message is attacker-shaped (a filter may raise error("cannot index …") itself; the worst that buys is a wrong hint). The engine's message is dropped and persisted nowhere. What an operator needs to find a broken template — the field label, a sha256 of the filter expression, and the class — goes to tracing::warn! instead, following the filter_audit_entry precedent that already logs expr_sha256 and never logs output values. services/response_filter.rs is explicitly excluded and keeps propagating jaq text verbatim. No backfill: existing rows are left as written.
Rationale: This generalises the rule services/credential_template.rs has enforced since it shipped ("jq's runtime errors embed their operands, and the operands here are credentials … Keep it that way") to the second place where that is true. It is true in jaq by construction, not by accident: jaq-core's Error::index, Error::math and Error::typ each interleave static strings with the values themselves, and run_jq_blocking flattens the result with format!("{e}").
Disclosure was doing the opposite — cap_message(msg), up to 512 characters of that text, straight onto DisclosedField.error — and it runs against the un-redacted projection. That input is not negotiable and must stay: the shipped Gmail template redacts body.raw and discloses To/Subject/Body extracted from body.raw, so redacting before extraction would delete the feature. The fix therefore has to sit at the error surface.
The sharp case is not the malicious template author, who per SPEC §12a Trust boundary can already put a redacted value straight in .value and is understood to be doing so deliberately. It is the accidental one: a template that did declare redact: [body.card_number] and discloses .body.card_number.last4 loses the redaction the moment a caller sends a plain string instead of the object shape the filter assumed. Nothing in the filter text tells a reviewer that, the value lands on two durable rows (approvals.disclosed_fields, audit_log.detail.disclosed) plus the inline pending_approval envelope the calling agent reads, and the error channel quotes the enclosing value rather than the addressed one — so .body + 1 yields every redacted path in the body at once, bypassing the per-field max_chars clamp that bounds the deliberate channel.
The response filter is left alone because its exclusion is principled rather than an oversight: its operand is the upstream response body, which the caller already receives on result.body. Same data, no new exposure — and its jq text is genuinely the most useful thing a caller debugging a filter can be handed. A classification is kept rather than a single bare string because the class comes from a whitelist and costs nothing, and "your dot-path indexed something that is not an object" is the one hint that actually shortens the round trip for a template author who can no longer read the message.
Date: 2026-08-12
Decision: Completes the gated-async path D62 reserved. permission_gate stamps approvals.execution_mode from the request's execution mode, and both triggers of a replay read it back: POST /v1/approvals/{id}/call and spawn_auto_call enqueue an async approval — an UPDATE lifting approvals.replay_payload into executions.request on the pending row — instead of claiming it and dialling. The enqueue happens at trigger time, not when the approval is resolved, so auto_call_on_approve = false keeps meaning "nothing runs until the agent says so" and services::inbox's ready_to_call keeps meaning what it says; ExecutionSummary grows a queued flag because a queued row sits in pending and the inbox, the approval page and the queue row all have to tell "queued on a worker" from "waiting on you". claim_for_execution gains AND request IS NULL, which is the load-bearing line: the enqueue leaves the row pending, exactly what the synchronous claim accepts, so without it a manual /call and a worker can both take the row and both reach the upstream. /call answers 202 with the ordinary ApprovalResponse (plus execution_mode and poll_after_ms), not the accepted envelope. On the worker an approval-backed row runs the same post-execution tail the inline replay runs — extracted to routes::approvals::tail — so the rules, the cascade, the approval.executed audit row and the approval event are identical whichever trigger dialled it, and its action.executed row is stamped AuditSource::Replay. An approval with no replay_payload, or a deployment that has since turned ASYNC_EXECUTION_ENABLED off, falls back to the inline synchronous replay. Cancelling a row a worker already owns falls through to the cooperative request_cancel and emits nothing — the worker announces the terminal state when it observes the flag. Two D62 defects are fixed as prerequisites: the async ceiling was never actually passed to call_timeout::resolve (so ASYNC_CALL_TIMEOUT_MAX_MS was unreachable and the feature could not escape the 110s cap it exists to escape), and the enabled check sat below the gate (so a gated async call on a flag-off deployment filed an approval instead of a 400). No migration: migration 112 already ships every column, and the create_pending_async_from_approval helper it shipped — written for the approve-time design — is retired.
Rationale: The alternative was queueing at approve time, which the already-shipped INSERT helper anticipated. It loses on the contract: auto_call_on_approve = false is a per-agent promise that an approved action waits for the agent, and a row queued at approve time runs anyway. It also breaks the inbox — the window between "approved" and "a worker claimed it" is unbounded when every replica is saturated or the flag is off, and throughout it the agent is being told to dispatch a row it cannot have. And it makes POST /v1/approvals/{id}/call, the documented resume path for MCP overslash_call({approval_id}) and overslash call, permanently unable to do anything but report "already queued". Trigger-time enqueue keeps one decision point and needs no second path.
The claim predicate matters more than any of the handler-level guards above it. /call also probes before falling back to the inline replay, and that probe catches the common race — but it is a check-then-act in a different transaction, and the case it cannot cover is a deployment that turned the worker off between the enqueue and the trigger, which skips the async branch entirely and goes straight to the claim. Excluding the two triggers by predicate makes "a gated async call is dialled at most once" provable from the SQL rather than argued from the order of handlers.
The 202 carries the existing envelope rather than a new one because both alternatives are worse in the same way: the dashboard assigns the response straight into its ApprovalResponse store and MCP forwards any 2xx body verbatim, so a second shape under one route is a runtime break with no type error to catch it. The HTTP code plus execution_mode is the honest signal, and ApprovalResponse has no status discriminator of its own to be consistent with — its status is the approval's.
Moving the tail rather than reimplementing it is what makes "an approved call owes the same things whichever trigger ran it" checkable: the tests assert counts, not existence, because a tail that was copied instead of moved passes an existence check and doubles the audit trail. It stays under routes::approvals so the tail → spawn_auto_call → execute_claimed_approval → tail cycle remains inside one module and spawn_auto_call stays private; the boxed dyn Future that breaks that cycle sits on the edge that closes it and is untouched.
D67: A key nothing reads is a warning on every path, and openapi::ext is the only thing that knows where each extension is read
Date: 2026-08-12
Decision: openapi::lint_extensions runs on the alias-normalized document at every validation entry point and reports four classes of key the compiler will silently ignore: unknown_extension (an x-overslash-* name nothing reads, with a closest_match suggestion), misplaced_extension (a known name at a position whose extractor does not read it), unprefixed_alias_ignored (a bare spelling at a position the alias walk does not rewrite), and unknown_template_key (an unrecognized bare key at a position whose fields we enumerate). Every one is a warning, never an error, on every path. Position comes from openapi::ext, a READS matrix of extension × position that every extractor now reads through via ext::get(obj, pos, ext); the accessor carries a debug_assert! against the matrix, and no_extension_getter_bypasses_the_accessor bans the obj.get("x-overslash-…") spelling in openapi/ production code. Enforcement lives in shipped_services_lint_clean, which filters on LINT_CODES rather than on warnings.is_empty() so an unrelated warning can neither disarm nor break the gate. Three positions are deliberately open-world for bare keys — request-body and MCP tool-input properties (JSON Schema), a discovered_tools snapshot (the MCP wire shape), a platform-action param, and any unrecognized security-scheme type — because at those positions the sibling keys are vocabulary we do not own, and a payload field genuinely named risk or template must not be reported. A position's own declared fields also win over the extension vocabulary, which is what keeps x-overslash-mcp.auth.provider — a read field that shares a name with an oauth2 scheme's provider alias — from reading as misplaced. Two normalizer/reader disagreements found while writing the matrix are fixed rather than reported: APIKEY_HTTP_SEC_ALIASES is split into APIKEY_SEC_ALIASES / HTTP_SEC_ALIASES, since extract_http_auth reads only default_secret_name and label and generates its own injection template; and normalize_parameters_in gains normalize_body_properties_in, so an unprefixed resolve: in a request body works instead of being a no-op — the HTTP twin of D55's input_schema walk, and a live behaviour change with zero shipped-template impact (all nine body-property annotations already use the canonical spelling). Path-item level stays un-extended on purpose: risk: hoisted out of a method is not a concept, so the lint reporting it is the right outcome. registry::load_from_dir logs findings and still loads the template; template_resolve lints the stored document into the resolution report the catalog already badges; validate_delta re-roots a finding's dot-path from the synthetic paths.{path}.{method} onto extensions.actions.{key}.operation, so it names something the author can find.
Rationale: services/metabase.yaml carried response_type: binary on export_query for months and it did nothing — the compiler only derives a response type from a responses: block, so a large xlsx export was buffered against max_response_body_bytes and the only evidence was the absent prefer_stream hint. D57 fixed that instance; nothing would have caught the next one. Neither motivating bug is a misspelling, and that decided the scope: response_type is a ServiceAction field name and resolve: is an alias, so a lint over x-overslash-* names alone would have caught neither, while a closed-world bare-key check alone would have missed x-overslash-download on an HTTP operation and every stray key at an open-world position. Each half misses the other's motivating case, which is the argument that four rules is the scope rather than gold-plating. Warnings, not errors, because the two strict options are both worse than the disease. An error at load_from_dir means the template is skipped — a stray key would remove a service, where before it merely removed a field, and that is a strictly larger outage than the bug. An error on create/update would make an already-active stored org or user template un-saveable by its owner, on tenant data no one can survey, over a key that was inert the whole time. Neither is worth it when the population of shipped offenders is empirically zero and a CI gate holds the line for free. That leaves visibility as the real problem, which is why the lenient paths all had to grow a surface: POST /v1/templates/validate already rendered warnings in the editor but the header still read "Valid" over them; the draft page rendered import_warnings and dropped validation.warnings entirely; the layer editor rendered warnings with no path. template_resolve is the highest-leverage placement and the reason the severity question is answerable at all — it is the only thing that ever looks at a row written before the lint existed, so the badge count is how the affected population becomes visible without a migration or a grandfathering table, both of which would have preserved the bug class they were protecting. The accessor is the part that has to survive. A hand-maintained position table drifts in the direction that matters most: it claims a key is read somewhere it is not, and the lint then blesses the exact no-op it exists to catch — and a name-level source grep cannot see that, because it reconciles names while the lint needs name × position. It also could not have distinguished template on an apiKey scheme from template on an http one, which is a live instance of the bug. Routing reads through ext::get makes the matrix a precondition of the reader instead of a description of it, and the mechanism proved itself during this change: PR #550 landed x-overslash-icon on dev mid-flight, and the guard failed on the unregistered reader immediately rather than shipping a lint that warned about a correct template. The .get("x-overslash-…") ban is scoped to reads and deliberately leaves compound dot-paths and message text alone — those are not reads, and routing them through Ext::key() would buy nothing and cost legibility. One honest limit, recorded in TECH_DEBT: the matrix's position claims rest on review, since only its name claims are mechanically checked.
D68: A hybrid call is a row that is durable before it is dialled, and a hybrid row is failed, never re-dialled
Date: 2026-08-13
Decision: execution: "hybrid" on POST /v1/actions/call, behind the same ASYNC_EXECUTION_ENABLED flag as D62. The request path never dials: routes::actions::hybrid::start inserts an executions row already claimed by this process (create_hybrid_claimed — a single statement at status = 'executing', triggered_by = 'hybrid', under this replica's lease), spawns the ordinary async_executor::job::execute with JobMode::Hybrid { observer }, then races a oneshot receiver against sleep(handoff). Win the race and the caller gets the ordinary called envelope, rendered by the same render_stored the synchronous path uses, with a new optional execution_id correlating it to the row. Lose it and the caller gets the accepted envelope async_accept already produces, and polls GET /v1/executions/{id}. action.accepted is audited only on the handoff branch. The handoff is HYBRID_HANDOFF_MS (default 5s) with a per-call handoff_after_ms; a caller-supplied value above HYBRID_HANDOFF_MAX_MS, below 100ms, or not less than the call's own timeout_ms is a 400, while the deployment default is silently clamped to that budget — the split timeout_ms already makes. Hybrid resolves its D56 budget against the async ceiling, safe because the connection is bounded by the handoff and never by the budget. HYBRID_MAX_INFLIGHT (default 32) caps concurrent hybrid jobs per replica; over it, the call falls through to async_accept and is answered accepted on the ordinary queue, so saturation is invisible to the caller. ExecutionDetail.origin gains hybrid, and the list filter accepts it. Migration 114 widens approvals_execution_mode_check; a gated hybrid call is stamped 'hybrid' and then queued exactly as async is (ApprovalRow::is_async matches both).
The invariant: a hybrid row never returns to pending. claim_async_batch takes pending AND request IS NOT NULL, so any path back to pending hands a live upstream request to another replica to send a second time, and an action call has no idempotency key. Three statements could do it, and all three now carry triggered_by IS DISTINCT FROM 'hybrid': requeue_expired_leases, fail_exhausted_async, and release_async. A hybrid row whose lease expires is failed by a dedicated sweep with the distinct reason hybrid_instance_lost, and the job's own shutdown path finalizes worker_lost rather than releasing.
Rationale: The obvious framing — "sync that gets promoted at a timeout" — cannot be built, and TODO.md recorded why: an in-flight upstream request cannot be handed to a leased row without either sending it twice or degrading to a detached task with no durable record. Inverting it dissolves the problem. Hybrid is async that the connection waits on: the job owns a durable row from before the first byte and dials exactly once, and the connection is a spectator with a deadline. Nothing is handed over at the deadline except who reports the result, which is why both response shapes describe the same row and why the 200 branch can reuse the synchronous renderer instead of a parallel one.
Inserting before dialling rather than lazily at the handoff costs an INSERT on calls that never need it, and buys the property the mode is named for. The lazy alternative leaves the window between dial and handoff with no durable record at all — a crash there means the side effect happened and nothing anywhere knows — and it puts a database write on the handoff path, where a failure leaves a live upstream call with nowhere to put its result. The accepted consequence is an availability inversion: a database outage turns a hybrid call into a 500 where a synchronous one would have succeeded. That is the correct direction to fail, because the alternative is dialling an upstream with nowhere to put the answer.
Excluding hybrid from both reclaim arms rather than one is not belt-and-braces. The two are disjoint by arithmetic on attempts, so at the default max_attempts = 1 a hybrid row lands in the exhaust arm and is failed — accidentally right. Raise the knob and the same row lands in the requeue arm and is dialled again. Excluding both is what makes the invariant independent of an operator's configuration, and a_hybrid_row_still_fails_when_max_attempts_is_raised is the test that pins it.
A gated hybrid call is queued, not raced, because spawn_auto_call has no connection to race on at all — so racing the other trigger would make one approval behave differently depending on which of its two triggers fired, the exact failure D66 was written to prevent. The connection that would be racing is a resolver's browser, not the caller whose latency budget motivated the mode. execution_mode still stores 'hybrid' rather than folding to 'async' at stamp time: both triggers treat them identically, but a lossy stamp cannot be un-lost, and the approval card should be able to say which mode was asked for.
StoredOutcome had to grow typed: ActionResult rather than have the connection re-parse the row's JSON, because run_platform stamps a runtime key into that JSON and run_mcp produces the MCP shape — both deliberately, for the row. It also grew is_error separate from upstream_errored: those are the >= 400 and >= 500 rules, and collapsing them would mis-report every HTTP 4xx and every MCP in-band tool error, whose status_code does not encode the flag. The inline report is sent after finish returns, so the connection can never answer 200 for a row the database has not accepted, and a lost lease reports nothing at all — the sender drops, the caller gets 202, and reads whatever the row's real owner wrote. Every non-answer ending (cancelled, lease lost, shutdown, panic) is signalled the same way, by dropping the sender, which is one less state to keep in sync with the row.
Metrics get a purpose-built overslash_hybrid_calls_total{outcome} and overslash_hybrid_handoff_seconds rather than a new value on record_execution's mode, which is the call shape. Folding handed-off calls into the existing duration histogram would fill it with samples clustered at the configured handoff, making its p99 a reading of the deployment's own config rather than of its upstreams.
D69: A SELECT is a read only while every function it calls is one, and Postgres's own volatility catalog decides which those are
Date: 2026-08-13
Decision: sql_policy::analyze now screens function calls, not only statement shape. A top-level SELECT that clears the D42 gauntlet (single statement, no writable CTE, no SELECT … INTO, no row locking) classifies read only if every function it invokes is on the safe list; any miss returns WriteReason::UnsafeFunction, which elevates the risk floor to write and sets tables_exhaustive = false, so the call mints the table_mut={label}/* sentinel instead of the per-table keys the parser enumerated. The safe list is two lists. The bulk is generated from pg_catalog: every IMMUTABLE or STABLE function, aggregate and window function, committed as sql_policy/catalog_functions.rs (2 475 names, byte-sorted, binary-searched) by scripts/gen-sql-safe-functions.sh against the Postgres major libpg_query vendors. Postgres's own contract is that a non-volatile function "cannot modify the database", so volatility — not our taste — draws the line, and it lands nextval, setval, set_config, pg_read_file, lo_import, dblink and pg_terminate_backend on the far side without our naming one of them. Two classes of non-volatile function are subtracted by the generator: the relation-slurping XML functions (table_to_xml, schema_to_xml, database_to_xml and their …schema variants), which are STABLE but read a relation named at runtime and would therefore break the table enumeration; and txid_current / pg_current_xact_id, which are STABLE but assign a transaction id. The second list is a hand-maintained VOLATILE-but-harmless carve-out in sql_policy/functions.rs — pg_sleep, pg_sleep_for, pg_sleep_until, random, random_normal, gen_random_uuid, clock_timestamp, timeofday — each with a comment saying what it cannot change. Names are compared exactly after stripping one leading pg_catalog.; anything still carrying a . is schema-qualified outside the catalog and never matches, and a quoted "COUNT" correctly misses the lowercase count. The escape hatch is per database: SqlDatabaseEntry gains safe_functions: [] in the existing sql_databases instance config, normalized the same way, so an operator unblocks their PostGIS st_*, their unaccent, or an in-house UDF with a config edit rather than a release. The offending names ride in WriteReason's payload — capped at five plus a count — into audit_log.detail and the approval detail block, and deliberately not into the tag index, which gets sql_reason:unsafe_function and nothing more.
Rationale: D42 wrote the limitation down and shipped it: "volatile functions inside a SELECT (SELECT nextval('s')) classify read — function-level policy is out of scope, DB grants own it." Delegating to DB grants is sound in principle and hollow in practice, because the gateway is exactly the layer an org reaches for when it cannot re-grant the upstream — the Metabase service account is shared, and a risk: dynamic action's whole promise is that Overslash tells read from write on that account's behalf. Under the old rule SELECT pg_read_file('/etc/passwd'), SELECT dblink_exec('…','DELETE FROM t') and SELECT query_to_xml('DELETE FROM t', …) were all reads that auto-approved and executed, and the last two are arbitrary write execution through a statement the classifier had just certified.
The generated list is the load-bearing choice, and the alternative was a denylist. A curated list of dangerous functions is smaller, easier to review, and wrong in the direction that costs: it is an enumeration of badness over a namespace that extensions and every CREATE FUNCTION extend at runtime, so a UDF — the one thing whose body the parser provably cannot see — sails through by default. Fail-closed inverts that, and the reason it is affordable at all is that Postgres already publishes the answer. Hand-curating ~400 common functions was the third option and is strictly worse than generating 2 475: same failure mode, more work, and no principle behind an omission.
pg_sleep is why the carve-out exists rather than a footnote. Volatility alone would refuse it, and refusing it would be indefensible — it burns wall-clock in the caller's own backend and touches nothing. That it sits next to nextval under the same provolatile = 'v' is the proof that the catalog is a floor for this policy, not the whole of it. The carve-out is kept short on purpose: each entry must survive "what can this change?", and a test asserts none of them has since become STABLE, which would mean the generator now covers it and the justifying comment has gone stale.
Dropping tables_exhaustive is the half that makes the elevation real. Classifying write while still claiming the table lists are complete would let a table_mut={label}/public.film grant authorize SELECT my_udf(id) FROM public.film, and the UDF body reaches anything it likes — the relations in the FROM clause are not the relations the statement touches. This is the same reasoning DO/CALL/EXECUTE already got, applied to the case where the opaque body is a function's instead of a block's. The cost is honest and visible: an unlisted-but-harmless function now needs an all-tables mutation grant, which is exactly the pressure that makes safe_functions get used instead of metabase:**.
The escape hatch is per database and not global because vouching for unaccent on a reporting replica says nothing about production, and the config already keys on the database for dialect and label. It widens the read/write boundary, so it belongs where a reviewer already looks to see which database a grant names.
The names stay out of the tag index for the reason ParseError's message does: they are caller-controlled and unbounded, and one agent looping over f1(), f2(), … would turn the audit tag namespace into a cardinality bomb. sql_reason:unsafe_function is enough to find the rows; detail answers which function.
The enumeration is checked, not trusted, and that is the part that nearly shipped wrong. The first draft read function names off pg_query's nodes() iterator, which the crate documents as covering "a subset of nodes" and which is in fact a hand-written per-variant field list. It silently skips seven positions — an aggregate's FILTER, LIMIT/OFFSET, a VALUES row, DISTINCT ON, a window frame's ORDER BY, agg_order, and an array subscript — so SELECT count(*) FILTER (WHERE nextval('s') > 1) FROM t classified read with the gate in place. For D42's table enumeration a gap like that is a known imprecision; for a screen it is a bypass, because a call nobody reached is a call nobody screened. Two things fix it and both are needed: blind_spots re-roots the walk at each dropped field, which buys back precision; and count_func_calls renders the tree through a counting sink — prost derives Debug structurally over every field, so one FuncCall { appears per call wherever it sits — and any shortfall against the walk fails the statement closed under a reserved <unenumerated call> name. The oracle is what makes the guarantee survive a pg_query upgrade, since it rests on prost's derive rather than on anyone's field audit staying current; the 27-position no_call_hides_from_the_walk test is its specification and only ever grows. It costs ~12% of an already sub-millisecond classify, and only on statements that would otherwise have been reads.
Operators are deliberately not screened. An A_Expr names an operator, not a function, so a user-defined operator backed by a volatile function slips the gate. Closing it would mean resolving operators to their implementing functions, which needs the catalog we do not have at classify time; the exposure requires a custom operator to already exist in the database, and the read-only upstream credential still backstops it. Recorded as a non-guarantee in the module docs rather than half-solved.
The change is not backwards compatible for existing risk: dynamic callers, and that is intended rather than tolerated: every query it reclassifies was one the old rule certified as a read without evidence. Queries using extension functions or in-house UDFs go from read to write-plus-sentinel on deploy, fixed by safe_functions without a release. The measured blast radius is small — over a 26-query corpus of ordinary analytics SQL (window functions, percentile_cont … WITHIN GROUP, jsonb_build_object, string_agg, to_char, extract, date_trunc, coalesce/nullif/trim, greatest/least) exactly one classifies write, and it is the deliberate nextval. Several of those never reach the screen at all: COALESCE, NULLIF, GREATEST and LEAST are grammar constructs rather than pg_proc entries, which is precisely why a token-level screen was rejected — it would have failed all four.
Date: 2026-08-13
Decision: An agent renders as the logo of the MCP client bound to it, drawn over three colours derived from the last nine bytes of sha256(agent id), one byte per channel. Both halves are resolved server-side onto IdentityResponse as icon_url / icon_stripe / mcp_client_label, and both are pure functions of data already stored — so there is no identities.icon column, no migration, and no icon picker. The client is identified by normalized-substring match against a static table in overslash_core::mcp_client_icon, tried over clientInfo.name (the initialize handshake), then oauth_mcp_clients.client_name (DCR), then software_id; anything unmatched resolves to client_unknown, a bot glyph we author ourselves, so an agent mark is never absent. Client marks live in the existing assets/service-icons/ set under a client_ prefix, which is what keeps them out of ServiceIcon::implicit_for_key's builtin:<template key> rule — an unprefixed cursor.svg would silently become the icon of any future service template keyed cursor. They follow D63's manifest discipline unchanged: simple-icons where a mark exists, pending with owner + guidelines + note where it does not (client_vscode, client_chatgpt), and a pending client degrades to the letter tile exactly as a pending service does. GET /v1/identities resolves every agent's client in one DISTINCT ON query (clients_for_agents) whose tie-break matches get_by_agent_identity's, and the plain From<IdentityRow> impl is replaced by from_row(row, &IdentityIconCtx) so the lookup cannot be forgotten.
Rationale: Users and services both got real marks (D63, #549, #550) and an agent was the last identity kind still drawn as a bare name — TODO.md had carried "identities and services carry no icon field" as the reason the Live Map rendered monograms. The client logo is the useful default because it is the one thing about an agent a reader did not choose and cannot infer from the name: releaser tells you nothing about whether it is Claude Code or a cron job. But a logo alone makes siblings indistinguishable — a team running five Claude Code agents gets five identical rows, which is worse than the monogram it replaced, and that objection is what the stripe answers rather than a second mechanism bolted on. Hashing the agent's id, not the client's, is the whole point: the logo already carries the client, so the stripe has to carry what the logo cannot. A picker was considered and rejected: it needs a column, a migration, a PATCH surface, an ACL answer (identity mutation is AdminAcl today, which would put a cosmetic choice behind org-admin), and a UI — all to hand-maintain something the binding already knows, and all of which can still be added later on top of this without changing what an un-picked agent renders. The From impl had to go. With it, an endpoint that skipped the enrichment still compiled and still returned valid JSON — it just quietly drew every agent as the generic bot, a failure with no error and no visual signal that anything was missing. Making the context a constructor parameter turned that into a compile error, and it immediately caught patch_auto_call_on_approve, which nothing in the plan had listed. Matching is deliberately substring over a normalized string rather than equality, because none of the three sources is a controlled vocabulary — they are free text chosen by whoever wrote the client, and Claude Code 2.1.0, claude-code and claudeCode all have to land on one mark. Ordering the table specific-before-general (claudecode before claude, githubcopilot before copilot) is asserted by a test rather than left to reading order, since the failure mode is silent: a general needle listed first makes every later specific one unreachable. Nothing here gates access, so a wrong guess costs a wrong logo and never a wrong permission — which is why the table can afford to be lenient.
D71: A decision number is allocated when the PR merges, and until then the author writes a placeholder
Date: 2026-08-14
Decision: A new entry in this file is written with a placeholder heading and referred to by that placeholder everywhere else in the same PR. .github/workflows/allocate-decision.yml rewrites it to the real number across every tracked file on the push to dev, and commits the result as chore(docs): allocate D<n> (#<pr>). Decision numbers no longer appear in commit subjects or PR titles. scripts/check-decisions.sh runs in CI (the docs job, unconditional — the lint job's rust filter skips exactly the docs-only PRs that move a number), in make check, and in the pre-commit hook. See docs/runbooks/decision-numbering.md.
The number was never valid when it was picked. max + 1 is chosen at authoring time and only means anything at merge time. Between those two moments every decision that lands first invalidates it, and the file is append-only, so two branches also always collide on the same hunk. That collision is loud and costs a minute. The stale number is neither: it is denormalized into roughly ten files across five languages — STATUS.md, TODO.md, SPEC.md, Rust comments, Svelte, migrations, service YAML, Terraform description strings — and a renumber that misses one leaves a reference pointing at an unrelated decision, which reads plausibly and never errors.
Rationale: Throughput made a latent problem structural. Jul 24 – Aug 10 averaged 0.61 decisions a day; Aug 11–13 produced 6, 7 and 4, so 18 of the first 70 decisions — a quarter of the file's history — landed in eight days. With about a day of review latency, six or so decisions are in flight at once and a freshly picked number is stale on arrival. Nine of the 47 commits touching this file since July mention renumbering; #546 renumbered three times in one PR and still merged with a subject naming D60 for what shipped as D62, #542's renumber reached this file and none of the six others that cited it, and #547 shipped as D61 while five files kept its authoring number, one of them crediting D62 — a decision from an unrelated PR. Fourteen citations were wrong when this was written, all of them naming a decision that exists. No lint catches that class — both cite numbers that exist — so allocation had to move rather than merely be checked.
Two alternatives were rejected. merge=union on this file in .gitattributes removes the textual conflict, but that conflict is currently the only thing detecting a duplicate number; without a lint first it trades a loud problem for a silent one. One file per decision under docs/decisions/, with this file generated as an index — the docs/design/INDEX.md pattern — genuinely removes the textual conflict, but it does nothing about the number being guessed and makes duplicates more silent, since 0071-a.md and 0071-b.md merge cleanly where a shared hunk would not. It also costs a 70-entry migration plus 169 cross-references. Worth revisiting only if the EOF conflict, now reduced to "keep both placeholders", turns out to still hurt.
Date: 2026-08-14
Decision: A display name may be supplied alongside the identifier on every surface that names a person. Impersonation gains a companion X-Overslash-As-Name header, accepted either literally or in the RFC 8187 UTF-8''<pct-encoded> form; it names the user root only and is applied in one direction — a root being provisioned now is created with it, a root that exists but has never signed in (external_id IS NULL, not an org admin) has it refreshed and audited as identity.updated, and an adopted root ignores it entirely. The refresh runs after the ACL cap, and re-sending an unchanged name writes nothing. POST /v1/identities and PATCH /v1/identities/{id} gain a matching email field, user-kind only, with one live identity per email in an org (409 on duplicate) and the address changeable only while external_id IS NULL (409 after a sign-in), re-checked under FOR UPDATE inside the patch transaction. Names over 128 characters, empty, control-bearing, or undecodable are 400 — never truncated.
Rationale: For an agent the identifier is the name — the path segment alice@acme.com/henry names Henry. For a person it is not, and nothing carried the difference: user_name_from_email guessed a label from the local part, so a white-label backend that knew perfectly well it was acting for Alice Smith produced a member called alice, visible in the members list, on approval cards, and in audit_log.actor_name until her first SSO sign-in overwrote it. The encoding is the load-bearing detail. A header value is a byte string: fetch isomorphic-encodes it and throws above U+00FF, and HeaderValue::to_str() rejects the same bytes server-side, so José cannot be sent literally by any browser or Node client — the feature would have worked only for the ASCII half of its users. Percent-decoding every value was the alternative and quietly mangles 50% Club; RFC 8187's prefix is the same trade Content-Disposition's filename* already makes, so decoding is opt-in and a literal % is safe. The decoder is strict where the common crates are lenient: %ZZ is a 400 rather than a pass-through, because a caller that meant to encode and got it wrong should not have the mistake persisted as someone's name. Adoption is the boundary in both halves, and for one reason: the OAuth callback adopts a pre-created identity by verified email, so the address decides which human can claim the account and the IdP profile is the better source for the name. Before adoption both are placeholders anyone authorized may improve; after it, header traffic must not fight the IdP and an admin must not silently repoint who can claim the row. Org admins are excluded from the header rename even while unadopted — a deliberately narrower guard than "unadopted", since the blast radius is larger and the value smaller. Ordering against the ACL cap is the security-relevant choice. Provisioning already ran before the cap and stays there: a row that did not exist a moment ago leaks nothing. A rename does not have that excuse — it writes to a row the request did not create — so resolve_target reports the renameable root back and the extractor applies it only once the cap has agreed, which is what stops a refused impersonation from leaving a rename behind on its way to a 403. The name <> $3 predicate is not an optimisation either: this runs on the auth path of every request, and steady-state traffic sending the same name must not write a row per request. Extending the CRUD API rather than only the header keeps the three admission paths (invite, impersonation, admin create) converging on one row with one set of invariants; /v1/org-invites stays the "…and tell them" path, since it sends mail and handles the admin role, and it is the one endpoint that still cannot express a name.
D73: An action may declare the mode a call defaults to, and a template that declares an impossible one is demoted, not obeyed
Date: 2026-08-14
Decision: x-overslash-wait-mode on an operation or an MCP tool — with x-overslash-handoff_after_ms beside it — is rung 2 of the execution-mode cascade, under CallRequest.execution and above a floor of sync. Resolution is a pure services::wait_mode::resolve, shaped like D56's call_timeout::resolve and folded once in call.rs, below resolve_request (where the rung first exists) and above the D56 ceiling, the approval stamp and both dispatch forks. The caller outranks the template in both directions: execution: "sync" on an action declaring hybrid runs synchronously, so the key is a default and never a cap. There is deliberately no service rung, no orgs column and no ActionPatch — "this export is slow" is per-action knowledge, and a whole-service default would mostly make fast actions defer for nothing; info.x-overslash-default_wait_mode stays unclaimed if that is ever wanted. ExecutionMode moves from routes::actions::dto into overslash-core so the compiler and the request share one enum, and ActionSummary gains wait_mode so the declaration is visible before anyone calls the action. A template default that cannot be honoured is demoted to sync silently: prefer_stream, deliver: "url", return_url, runtime: platform, a binary response, or ASYNC_EXECUTION_ENABLED off each drop the rung, while a caller naming the same mode against the same flag still gets the existing 400. flags::validate_resolved returns the two template-shaped blockers rather than only refusing on them, so the demotion rule and the refusal rule read the same facts. The handoff rung takes the deployment side of that split — clamped to [100ms, HYBRID_HANDOFF_MAX_MS] and to the call's own budget, never refused. handoff_after_ms alone is no longer a 400: the check moves to flags::validate_effective, after the fold, since against an action declaring hybrid it is a coherent request.
The name is wait-mode, not execution. info.x-overslash-execution is claimed by docs/design/policy-engine-mode.md for external-versus-gateway execution, and two keys of the same name at different positions would be a lint the vocabulary cannot express. The cost is a real one, recorded here because it will confuse someone: the extension key and the request field it defaults have different spellings.
Rationale: D56 refused to auto-promote an over-ceiling call and D62 quoted that refusal — a response shape must not change "based on a number in a template the caller never saw". Both predate hybrid, and hybrid is what makes the refusal worth narrowing. It is the one mode whose surprise is bounded: the caller still gets the answer inline whenever the upstream is fast, and the worst case is a 202 and one extra poll. The status quo for a four-minute action is a 504 at the synchronous ceiling, which is not the safer outcome — it is the same surprise with no result attached. What D56 was really protecting is preserved intact, because the caller can always say sync and the envelope names the rung that chose otherwise.
The demotion is the load-bearing half, and it is not symmetry-breaking for convenience. It is D56's own clamp-versus-refuse asymmetry moved from a number onto a mode: a caller-supplied value that violates a bound is a 400 because the caller is present and can act on it, while a template default that violates one is narrowed, because the template author is not the caller and a mistyped value that 400s every call in the org is strictly worse than one that quietly does what every call did before the key existed. Refusing instead would make x-overslash-wait-mode the first extension key that can take an action down — a shipped template tagging a binary-returning export hybrid would 400 every call to it, for every org, over an annotation that was inert the day before. That is a strictly larger outage than the bug it would report, which is the argument D67 already settled for the extension lint.
Silent to the call is not silent to us, and that is the whole cost of choosing leniency. A demotion emits a tracing event naming the blocker, increments overslash_wait_mode_demoted_total{reason} — unlabelled by template on purpose, since reason is a closed set of six while a template dimension would grow with the catalog on a series that should sit at zero — and the two deferred envelopes carry execution_mode_source, skipped when the caller named the mode. Without that field an agent receiving a 202 it never asked for cannot tell "this action defers by declaration" from "something odd happened once", and would have to guess whether to expect the same shape next time. The counter is the only thing that makes a wrong declaration visible at all: every affected call still returns 200, so the author's mistake is invisible by construction from the outside.
No migration. approvals.execution_mode already admits all three values (114) and ApprovalRow::is_async already matches hybrid, so a gated call whose mode came from a template stamps and queues exactly as an explicitly-named one does — D66 and D68 are unchanged, and a template rung reaching the gate was already the shape they described. Nothing about a deferred call is stored differently for having been declared rather than requested, which is what keeps the rung honestly a default rather than a second kind of call.