diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c67865681..45c717bd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -454,7 +454,7 @@ jobs: - name: Install OpenTofu uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 with: - tofu_version: "1.8" + tofu_version: "1.13" tofu_wrapper: false - name: Format check diff --git a/.sqlx/query-1dd2e60c7b39789db4fd126784dccc7a27080a5f7dea5d42af0afc77b77877db.json b/.sqlx/query-0390276a145cf74b5d8e3e2f85b2e6056319d5d685559a97f5d43e02ec73d8e5.json similarity index 72% rename from .sqlx/query-1dd2e60c7b39789db4fd126784dccc7a27080a5f7dea5d42af0afc77b77877db.json rename to .sqlx/query-0390276a145cf74b5d8e3e2f85b2e6056319d5d685559a97f5d43e02ec73d8e5.json index 11eb06866..82b241c85 100644 --- a/.sqlx/query-1dd2e60c7b39789db4fd126784dccc7a27080a5f7dea5d42af0afc77b77877db.json +++ b/.sqlx/query-0390276a145cf74b5d8e3e2f85b2e6056319d5d685559a97f5d43e02ec73d8e5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT default_deferred_execution, default_agent_self_setup, call_timeout_ms, max_call_timeout_ms\n FROM orgs WHERE id = $1 FOR UPDATE", + "query": "SELECT default_deferred_execution, default_agent_self_setup, call_timeout_ms, max_call_timeout_ms,\n default_remember_ttl_secs\n FROM orgs WHERE id = $1 FOR UPDATE", "describe": { "columns": [ { @@ -46,6 +46,17 @@ "name": "max_call_timeout_ms" } } + }, + { + "ordinal": 4, + "name": "default_remember_ttl_secs", + "type_info": "Int4", + "origin": { + "Table": { + "table": "orgs", + "name": "default_remember_ttl_secs" + } + } } ], "parameters": { @@ -57,8 +68,9 @@ false, false, true, + true, true ] }, - "hash": "1dd2e60c7b39789db4fd126784dccc7a27080a5f7dea5d42af0afc77b77877db" + "hash": "0390276a145cf74b5d8e3e2f85b2e6056319d5d685559a97f5d43e02ec73d8e5" } diff --git a/.sqlx/query-73d37304206f85ae5a92dbdadaf954e0ab2f4c556a857feb98adae6c78f5192c.json b/.sqlx/query-73d37304206f85ae5a92dbdadaf954e0ab2f4c556a857feb98adae6c78f5192c.json new file mode 100644 index 000000000..67c63c90f --- /dev/null +++ b/.sqlx/query-73d37304206f85ae5a92dbdadaf954e0ab2f4c556a857feb98adae6c78f5192c.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT default_remember_ttl_secs FROM orgs WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "default_remember_ttl_secs", + "type_info": "Int4", + "origin": { + "Table": { + "table": "orgs", + "name": "default_remember_ttl_secs" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "73d37304206f85ae5a92dbdadaf954e0ab2f4c556a857feb98adae6c78f5192c" +} diff --git a/.sqlx/query-260a1ab8de1ea4997f5802ca889105e22ac0c8da565d0f7941f498d66d7262fd.json b/.sqlx/query-e3412d889cff77af86f7441ec2abbabd9b87734dc6b5798d6a5493599b83be5e.json similarity index 65% rename from .sqlx/query-260a1ab8de1ea4997f5802ca889105e22ac0c8da565d0f7941f498d66d7262fd.json rename to .sqlx/query-e3412d889cff77af86f7441ec2abbabd9b87734dc6b5798d6a5493599b83be5e.json index cca348da9..8cf4e6a4c 100644 --- a/.sqlx/query-260a1ab8de1ea4997f5802ca889105e22ac0c8da565d0f7941f498d66d7262fd.json +++ b/.sqlx/query-e3412d889cff77af86f7441ec2abbabd9b87734dc6b5798d6a5493599b83be5e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE orgs\n SET default_deferred_execution = $2,\n default_agent_self_setup = $3,\n call_timeout_ms = $4,\n max_call_timeout_ms = $5,\n updated_at = now()\n WHERE id = $1", + "query": "UPDATE orgs\n SET default_deferred_execution = $2,\n default_agent_self_setup = $3,\n call_timeout_ms = $4,\n max_call_timeout_ms = $5,\n default_remember_ttl_secs = $6,\n updated_at = now()\n WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -9,10 +9,11 @@ "Bool", "Bool", "Int4", + "Int4", "Int4" ] }, "nullable": [] }, - "hash": "260a1ab8de1ea4997f5802ca889105e22ac0c8da565d0f7941f498d66d7262fd" + "hash": "e3412d889cff77af86f7441ec2abbabd9b87734dc6b5798d6a5493599b83be5e" } diff --git a/Cargo.lock b/Cargo.lock index d0dc00cf3..7bc72aad4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -505,9 +505,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -630,7 +630,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", "inout", ] @@ -1147,7 +1147,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", diff --git a/DECISIONS.md b/DECISIONS.md index 9acd6315c..19049f5ce 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1537,3 +1537,14 @@ The disclosure input parses a form's body as JSON, as it does for a JSON body. **Decision**: For Overslash-managed sign-in (no `org_idp_configs` row claims the provider), login uses the deployment's `{PROVIDER}_AUTH_*` app even when the org has saved OAuth App Credentials (`OAUTH_{PROVIDER}_CLIENT_ID/SECRET` org secrets) for that provider. Those credentials still override env for service connections (SPEC §7). An admin can opt a provider in: a row in `org_oauth_signin_overrides (org_id, provider_key)`, off by default, makes the org's credentials serve sign-in too. It is set from the *Use for sign-in* action on a Google/GitHub row in Org Settings → OAuth App Credentials (`PATCH /v1/org-oauth-credentials/{provider} {override_signin}`), shown only when the org override exists and the env sign-in app is configured, or from the same checkbox on the save form (`PUT … {override_signin}`). Deleting the credentials clears the opt-in. Dedicated IdP rows, including those with `use_org_credentials`, are unchanged: they are already an explicit sign-in choice. There is no backfill, so orgs that had saved Google/GitHub credentials go back to the built-in sign-in app until an admin opts in. `services::org_signin::resolve_org_signin_credentials`; tests in `tests/org_oauth_credentials.rs`. **Rationale**: Admins save org OAuth credentials to brand or scope service connections, and the old precedence quietly moved every member's login onto that app as well. That app may have a narrower consent screen, test-user limits or unverified status, so login could break for reasons unrelated to sign-in. Making the sign-in takeover a separate, visible opt-in keeps "configure connections" from changing "how people log in". +## D141: `/health` fails when Postgres is down; Cloud Run probes are TCP + +**Date**: 2026-10-27 +**Decision**: `GET /health` returns 503 `status: degraded` when its bounded `SELECT 1` fails, and 200 `status: ok` otherwise. `/ready` keeps the same gate. The Cloud Run startup and liveness probes on the API are `tcp_socket` probes on the container port, not HTTP probes on `/health`. The GCM uptime check behind `[P0] API Down` and the Better Stack monitor stay on `/health`, so a database outage pages in prod. Dev's nightly Cloud SQL stop therefore fires dev's `API Down` every night, and that is accepted: it emails only, and it proves the path end to end. `routes/health.rs`, `infra/modules/cloud-run/main.tf`. +**Rationale**: `/health` was always-200 so that a Cloud SQL blip would not make the probes restart containers. That never worked. The probe's default 1 s timeout is shorter than `/health`'s 2 s database timeout, so with the database gone the liveness probe failed anyway. Dev's instance was shut down about 90 s after every nightly stop, and a replacement cannot boot without the database (`create_app` connects and migrates eagerly), so Cloud Run answered 500 until morning. The design gave neither a surviving container nor an honest status. Splitting the two questions fixes both. Whether the process is up is a TCP connect, which nothing upstream can fail. Whether the API can serve is `/health`, which the monitors read and which no longer restarts anything. A TCP startup probe gives up nothing, because the listener binds only after `create_app` has connected, migrated and preflighted. No new endpoint was added, since a TCP probe needs none. + +## D142: "Allow & Remember" expires after an org-set default, 7 days out of the box; an omitted `ttl` means that default, not forever + +**Date**: 2026-10-28 +**Decision**: Orgs have a `default_remember_ttl_secs` setting (migration 139). It is nullable, NULL means forever, it is bounded to 60 s to 365 d, and the column default is 604800 (7 days). The column default also backfills every existing org. Admins set it on `PATCH /v1/orgs/{id}/execution-settings`, and Org Settings shows it as a select below the call timeouts. When `POST /v1/approvals/{id}/resolve` gets `allow_remember` with no `ttl`, the rule expires `now + default`. A permanent rule now needs an explicit `ttl: "forever"`, which `/resolve` accepts (it used to 400 on it). The dashboard's Allow split button still starts on Once (#844), tags the org default "· default" in its duration menu, and always sends `ttl`. The MCP remember dialog preselects the org default; if that default is not one of the dialog's menu values, it is added as a `"{n}s"` option, and the dialog forwards `forever` verbatim. The SDK approval card gets an "Org default" choice, selected by default, that leaves `ttl` out. One parser, `overslash_core::types::duration::parse_rule_ttl`, serves `/resolve` and `PATCH /v1/permissions/{id}`. +**Rationale**: Before this, a remembered rule was permanent unless the reviewer changed the picker. "Never" was the preselection, and leaving the field out meant forever. So the cheapest action created the longest-lived grant. The org default had to be applied on the server, when `ttl` is missing, rather than only preselected in the dashboard, because API, MCP and SDK resolvers never see the dashboard. This changes behaviour for API callers that relied on omitting `ttl` to get forever. That trade is deliberate: any caller that wants a permanent grant can still ask for one, and now has to ask explicitly. Backfilling existing orgs instead of grandfathering them to forever is also deliberate, because the safer default should reach the orgs that never chose. Rules already written keep the expiry they were written with. diff --git a/SCHEMA.sql b/SCHEMA.sql index c0a006796..a1e9a9a2d 100644 --- a/SCHEMA.sql +++ b/SCHEMA.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- -\restrict IU7gGYzzNTd60ELJcMnX68xk6MwIMaSwIk56fgOPGg3Phe56vVK1sb2CM7pgn1c +\restrict Ima6egLcgrnJ79TnxwblysVaIC9DjWdCHPDn0FnJXFFtcuWvCqfvYirq9Tx4HXW -- Dumped from database version 16.14 (Debian 16.14-1.pgdg12+1) -- Dumped by pg_dump version 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1) @@ -1064,9 +1064,11 @@ CREATE TABLE public.orgs ( call_timeout_ms integer, max_call_timeout_ms integer, default_agent_self_setup boolean DEFAULT true NOT NULL, + default_remember_ttl_secs integer DEFAULT 604800, CONSTRAINT orgs_approval_auto_bubble_secs_check CHECK ((approval_auto_bubble_secs >= 0)), CONSTRAINT orgs_audit_response_body_mode_check CHECK ((audit_response_body_mode = ANY (ARRAY['off'::text, 'errors_only'::text, 'all'::text]))), CONSTRAINT orgs_call_timeout_bounds CHECK ((((call_timeout_ms IS NULL) OR ((call_timeout_ms >= 1000) AND (call_timeout_ms <= 600000))) AND ((max_call_timeout_ms IS NULL) OR ((max_call_timeout_ms >= 1000) AND (max_call_timeout_ms <= 600000))) AND ((call_timeout_ms IS NULL) OR (max_call_timeout_ms IS NULL) OR (call_timeout_ms <= max_call_timeout_ms)))), + CONSTRAINT orgs_default_remember_ttl_bounds CHECK (((default_remember_ttl_secs IS NULL) OR ((default_remember_ttl_secs >= 60) AND (default_remember_ttl_secs <= 31536000)))), CONSTRAINT orgs_plan_check CHECK ((plan = ANY (ARRAY['standard'::text, 'free_unlimited'::text, 'trial'::text]))), CONSTRAINT orgs_user_template_policy_check CHECK ((user_template_policy = ANY (ARRAY['none'::text, 'restrictive'::text, 'full'::text]))) ); @@ -1107,6 +1109,13 @@ COMMENT ON COLUMN public.orgs.max_call_timeout_ms IS 'Ceiling on any resolved ca COMMENT ON COLUMN public.orgs.default_agent_self_setup IS 'When true (default), a newly-created first-level agent (depth = 1) is seeded with the four overslash:*_own self-setup permission rules: manage_services_own, manage_templates_own, manage_connections_own, request_secrets_own. Existing agents are not touched when this flips.'; +-- +-- Name: COLUMN orgs.default_remember_ttl_secs; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.orgs.default_remember_ttl_secs IS 'Default expiry in seconds for rules written by Allow & Remember when the resolver does not pass a ttl. NULL = forever.'; + + -- -- Name: pending_checkouts; Type: TABLE; Schema: public; Owner: - -- @@ -4046,5 +4055,5 @@ ALTER TABLE ONLY public.webhook_subscriptions -- PostgreSQL database dump complete -- -\unrestrict IU7gGYzzNTd60ELJcMnX68xk6MwIMaSwIk56fgOPGg3Phe56vVK1sb2CM7pgn1c +\unrestrict Ima6egLcgrnJ79TnxwblysVaIC9DjWdCHPDn0FnJXFFtcuWvCqfvYirq9Tx4HXW diff --git a/SPEC.md b/SPEC.md index 049431b47..ea0e637cd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -497,7 +497,7 @@ The flow above expanded as discrete steps: 2. **Group check (Layer 1)**: is the service + access level within the owner-user's group grants? If not → **deny** (not approvable) 3. **Auto-approve bypass**: if the matching grant's `auto_approve_level` permits this action's risk, skip Layer 2 and call immediately (a deny rule still applies to mutating calls) 4. **Permission key check (Layer 2)**: are all derived keys covered by existing rules for this identity? If yes → **auto-approve** -5. If not → **create approval request** → user decides → "Allow & Remember" stores keys with optional TTL +5. If not → **create approval request** → user decides → "Allow & Remember" stores keys with a TTL (the org default — 7 days unless changed — or `forever` when asked for explicitly) ### Hierarchical Resolution @@ -609,6 +609,8 @@ Approval and secret requests are **not notified immediately**. Only requests tha "Allow & Remember" on an approval creates permission key rules with optional TTL. These rules auto-approve matching future requests. Permission rules and remembered approvals are the same concept — "permission rules" is the storage format, "remembered approvals" is the user-facing term. Users can view and revoke them per identity via the dashboard. +**Expiry.** The resolver may pass `ttl` (`1h`, `7d`, … up to 365 days, or `forever`). An omitted `ttl` takes the org's `default_remember_ttl_secs` (`PATCH /v1/orgs/{id}/execution-settings`; NULL = forever; **default 7 days**). The MCP remember dialog preselects that default; the dashboard's Allow menu tags it (the button itself starts on Once). Changing it never touches rules already written. + The rule is stored **only after a successful `POST /v1/approvals/{id}/call`** — a cancelled, expired, or failed replay leaves no rule behind. This prevents a reviewer from being silently committed to auto-approving an action they never saw succeed. ### Replay Semantics @@ -960,7 +962,7 @@ Templates live in a three-tier registry: **Org-admin visibility**: Org-admins can see all templates in the org (global + org + user-created) in a read-only list for security/compliance — they need to know what external APIs their users are connecting to. -**Layers (`extends`/`delta`).** Each org/user template row is a **layer**. A **standalone** layer (`extends` NULL) holds a full OpenAPI doc (the classic org/user template). A **derived** layer (`extends` set) holds a *delta* over a base template named by `extends` (resolved by key: DB rows then the global registry), and its effective template is the **fold** `resolve(layer) = apply(delta, resolve(extends))`. The delta's **masks** are restrictive and order-independent — action `allowlist` (∩) / `denylist` (\), per-action `risk` clamp-**up**-only, additive `disclose`, relabel, template `hidden` — so `resolve(child) ⊆ resolve(base)` (a child can never re-expose what a parent hid); its **extensions** add new actions/hosts only (no auth, no rebinding). An **org** layer may additionally carry `instance_defaults` — non-secret presets (endpoint `url` + `x-overslash-instance-config` values) that every service instance created from the layer inherits unless it sets its own (every template's endpoint is overridable per instance; `x-overslash-promoted` on `servers[0]` or on a config field only decides whether the instance form shows it up front or behind "Show more options"), so an org running its own deployment (its overfwd Mailbox Gateway, its self-hosted MCP server) names it once instead of on every user's instance. `extends` is a **live pointer**: editing a base (or Overslash shipping a new global version) propagates to every descendant immediately. `extends` and the layer's own `key` are decoupled — reusing the base key shadows it (curation that agents get transparently), a distinct key is a separate catalog entry alongside the base. Discovery, instantiation, and **execution** all resolve through the fold, so a masked-out action is unreachable everywhere. See [DECISIONS.md D29](DECISIONS.md) and [docs/design/layered-service-templates.md](docs/design/layered-service-templates.md). +**Layers (`extends`/`delta`).** Each org/user template row is a **layer**. A **standalone** layer (`extends` NULL) holds a full OpenAPI doc (the classic org/user template). A **derived** layer (`extends` set) holds a *delta* over a base template named by `extends` (resolved by key: DB rows then the global registry), and its effective template is the **fold** `resolve(layer) = apply(delta, resolve(extends))`. The delta's **masks** are restrictive and order-independent — action `allowlist` (∩) / `denylist` (\), per-action `risk` clamp-**up**-only, additive `disclose`, relabel (display name, description, summary), template `hidden` — so `resolve(child) ⊆ resolve(base)` (a child can never re-expose what a parent hid); its **extensions** add new actions/hosts only (no auth, no rebinding). An **org** layer may additionally carry `instance_defaults` — non-secret presets (endpoint `url` + `x-overslash-instance-config` values) that every service instance created from the layer inherits unless it sets its own (every template's endpoint is overridable per instance; `x-overslash-promoted` on `servers[0]` or on a config field only decides whether the instance form shows it up front or behind "Show more options"), so an org running its own deployment (its overfwd Mailbox Gateway, its self-hosted MCP server) names it once instead of on every user's instance. `extends` is a **live pointer**: editing a base (or Overslash shipping a new global version) propagates to every descendant immediately. `extends` and the layer's own `key` are decoupled — reusing the base key shadows it (curation that agents get transparently), a distinct key is a separate catalog entry alongside the base. Discovery, instantiation, and **execution** all resolve through the fold, so a masked-out action is unreachable everywhere. See [DECISIONS.md D29](DECISIONS.md) and [docs/design/layered-service-templates.md](docs/design/layered-service-templates.md). ### Template Definition @@ -1039,6 +1041,7 @@ paths: - **`x-overslash-pagination` / `pagination:`** — on an operation (or MCP tool), that this action returns one *page* of a larger collection, and how to reach the next one. Two halves. `page_size: {param, default, max}` names the declared numeric parameter that bounds a page; `default` **seeds that parameter's own `default:`** at compile time when it declares none, so the bound reaches the wire through `apply_defaults` like any other default (and appears on `/v1/search` rows for the same reason) rather than through a second injection path — a parameter that already declares one keeps it, and the two disagreeing is a warning. `max` is declarative: it bounds `default` at validation time and is *not* enforced against a caller who explicitly asks for more. `next: {style, param, from}` names the continuation: `cursor` (an opaque value read from the dotted body path in `from` — Slack's `cursor`, Notion's `start_cursor`, Google's `pageToken`), `offset` (advance `param` by the effective page size), `page` (increment `param` — whose own `default:` is required, since it is the only place a template says whether pages count from 0 or from 1), or `link` (the response names the whole next URL and the gateway lifts the declared query keys out of it — from the RFC 8288 `Link: <…>; rel="next"` header by default, or from the dotted body path in `from`, which is where Shortcut, Zendesk and Twilio put theirs; `param` there names the single query key the next URL may introduce although page one never sent it. Needs `from` on an MCP tool, whose results carry no headers). Optional `items` (dotted path to the principal collection) and `has_more` (dotted path to an explicit boolean) are how a last page is recognised. A **paged result** carries `_pagination: {has_more, next: {service, action, params}}`, where `params` is the *difference* between the call just made and the next one, in the action's own parameter names — merge it into what you sent. The gateway never follows a page itself. A malformed block is a template *error* and the action goes back to being unpaged; a `page_size.param` naming a parameter the action does not declare is an error too, since it reads as a bound and applies none. See §12b. - **`x-overslash-default_timeout_ms` / `default_timeout_ms:`** — under `info`, the same thing one rung less specific: the timeout every action of this service inherits unless it declares its own. The one-line answer to "this whole upstream is slow". - **`x-overslash-icon` / `icon:`** — under `info`, the mark the dashboard shows for this service. Two forms: `builtin:`, an asset Overslash ships and serves at `/icons/.svg`, and an `https://` URL hosted elsewhere. **Usually omitted**: a template whose key matches a shipped asset resolves to `builtin:` implicitly, which is why the shipped templates declare nothing. Explicit values are for the two cases the convention can't express — a key that deliberately differs from the asset it reuses, or a remote URL. Resolved server-side and surfaced as an absolute `icon_url` on the template and service-instance responses; a template with nothing renderable omits the field, and the dashboard falls back to a letter tile. Only `https://` ever reaches a browser — `http:`, `data:` and `javascript:` are template *errors*, checked both at write time and again when the response is built. Overslash never fetches a remote icon (that would be an SSRF vector and a boot-time network dependency), so there is no size or format validation of one. `${VAR}` expansion applies like anywhere else, which is how a self-hoster points the set at their own CDN. Icons are deliberately **not** on `/v1/search`: it fans out up to 100 rows per (instance × action) and the field would cost an agent's context window for something no model can render. +- **`summary:`** (standard OpenAPI 3.1 `info.summary`, not an extension) — under `info`, a one-line tagline for UI catalogs: one plain sentence, at most 80 characters, no trailing period. `info.description` is written for agents (it is folded into the text every action is scored against) and runs to paragraphs; `summary` is what a catalog tile shows, and consumers render `summary ?? description`. Surfaced as `summary` on `GET /v1/templates` (list, search, detail, admin, draft preview) and omitted when unset. Deliberately **not** on `/v1/search` or MCP: agents keep `description`. A multi-line or over-long value compiles with an `openapi_invalid` warning (an imported spec still loads); a derived layer may relabel it through `delta.summary`, which takes the same warnings. Every shipped template carries one, enforced by `every_shipped_template_has_a_short_summary`. - **`x-overslash-additional-properties` / `additional-properties:`** — on an operation or MCP tool, that the action's declared parameter set is a **floor, not a fence**: an argument the template never declared is forwarded upstream instead of returning `invalid_action_args`, and a declared `enum` names the members we know of rather than all of them. `required` is still enforced either way — it is a statement about the operation, not about how completely we transcribed it. Also settable under `info` as a service-wide default, where the nearest value wins, so an operation may write `false` to re-tighten under a service that relaxed globally. **Defaults to `false`**, and a malformed value (`additional-properties: "true"`) warns and stays strict, so a quoting mistake cannot grant the relaxation by accident. The name is borrowed from JSON Schema, where it governs undeclared keys only; here it deliberately covers `enum` too, because the same template that cannot name every parameter is the one whose enum snapshot has gone stale. For a relaxed action whose operation declares no `requestBody`, a non-`GET` call synthesizes a JSON body so the accepted argument actually reaches the wire; declaring the key on a body-carrying method whose `requestBody` is **non-JSON** is a template *error* (`additional_properties_needs_a_json_body`), since only JSON bodies are serialised and the argument would otherwise pass validation and then be dropped. **Not read on a platform action**: those answer from this process against a param set Overslash owns, so an undeclared argument would pass the gate and then be silently dropped — strictly worse than the 400. What the author gives up is the Levenshtein "did you mean" on a typo: a misspelled optional param now reaches the upstream instead of returning a self-correctable 400. Two security notes, both consequences of the gateway not knowing a name exists: permission-key derivation (`x-overslash-scope_param`) reads declared params only, so an undeclared argument can change what a call does without changing the key the chain checks; and `x-overslash-redact` cannot cover a path it has never seen, so a relaxed action's approval payload and audit row may carry undeclared values in the clear. Surfaced to agents as `additional_properties` on `/v1/search` rows, and to the dashboard on the action-detail response, which is what turns the API Explorer's enum `