{
  "$comment": [
    "HAND-MAINTAINED. This is the only place a CLI verb's in-app behaviour is decided.",
    "Project #275: the platform agent's tool surface is generated from the Commander tree,",
    "but Commander knows nothing about approval, mutation class or whether a verb belongs",
    "in the app at all. Those are product decisions and they live here.",
    "",
    "The generator fails when a Commander verb has no row here, and fails again when a row",
    "here names a verb Commander no longer has. Absence is never silent (US-3.3): a verb",
    "kept out of the app carries surface != 'both' AND a reason a human wrote.",
    "",
    "Fields per verb:",
    "  surface            both | cli_only | app_only - where the verb is callable",
    "  is_read_only       true when the verb cannot change stored state",
    "  mutation_class     none | kb | canvas | dashboard | data_source | governance",
    "                     (routes the validation-gateway pipeline and the loop guards)",
    "  requires_approval  true when the turn must show an approval card before executing",
    "  silent_retry_exempt true when a failure must surface instead of being retried silently",
    "  reason             REQUIRED when surface != 'both'",
    "  app_params         parameters the app has and the CLI does not, each { type, required,",
    "                     description, reason }. For verbs whose two surfaces differ in",
    "                     MECHANICS rather than capability - the only case so far is canvas",
    "                     HTML, which the CLI carries through --file/--stdin and an in-app",
    "                     turn has no filesystem for. A reason is REQUIRED for the same",
    "                     rule absence follows: an asymmetry nobody wrote down is one nobody",
    "                     reviewed. Never a way to give the app capability the CLI lacks."
  ],
  "nouns": {
    "kb_read": "Read semantic models, nested components, concrete metrics and families, groups, and retained rules",
    "kb_write": "Author semantic models, concrete metrics, groups, and retained rules",
    "ds": "Data sources - cached snapshots of warehouse queries and uploaded files",
    "dashboard": "Custom dashboards - the HTML canvas, its entities, drafts and exports",
    "query": "Run SQL against a cached data source or a live warehouse, through the governance gateway",
    "metadata": "Warehouse metadata discovery - schemas, tables and columns",
    "governance": "Governance conformance and the audit log",
    "status": "The caller's effective permissions per domain"
  },
  "verbs": {
    "auth login": {
      "surface": "cli_only",
      "is_read_only": false,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": true,
      "reason": "Browser OAuth that writes credentials to the caller's machine. An in-app turn is already inside an authenticated session, so there is nothing for the agent to log in to."
    },
    "auth status": {
      "surface": "cli_only",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false,
      "reason": "Reports the local credential file's state. In-app the session is the credential; `status` answers the question the agent actually has (what may I do?)."
    },
    "auth logout": {
      "surface": "cli_only",
      "is_read_only": false,
      "mutation_class": "none",
      "requires_approval": true,
      "silent_retry_exempt": true,
      "reason": "Clears credentials on the caller's machine. An agent that can log the user out of the app it is running inside is a footgun with no upside."
    },
    "status": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "kb get": {
      "surface": "both",
      "noun": "kb_read",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "kb list": {
      "surface": "both",
      "noun": "kb_read",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "kb tree": {
      "surface": "both",
      "noun": "kb_read",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "kb search": {
      "surface": "both",
      "noun": "kb_read",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "kb entity": {
      "surface": "both",
      "noun": "kb_read",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "kb family": {
      "surface": "both",
      "noun": "kb_read",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "kb explore": {
      "surface": "both",
      "noun": "kb_read",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "kb usage": {
      "surface": "both",
      "noun": "kb_read",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "kb create semantic-model": {
      "surface": "both",
      "noun": "kb_write",
      "is_read_only": false,
      "mutation_class": "kb",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "kb create metric": {
      "surface": "both",
      "noun": "kb_write",
      "is_read_only": false,
      "mutation_class": "kb",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "kb create group": {
      "surface": "both",
      "noun": "kb_write",
      "is_read_only": false,
      "mutation_class": "kb",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "kb create rule": {
      "surface": "both",
      "noun": "kb_write",
      "is_read_only": false,
      "mutation_class": "kb",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "kb update": {
      "surface": "both",
      "noun": "kb_write",
      "is_read_only": false,
      "mutation_class": "kb",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "kb delete": {
      "surface": "both",
      "noun": "kb_write",
      "is_read_only": false,
      "mutation_class": "kb",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "kb verify": {
      "surface": "both",
      "noun": "kb_write",
      "is_read_only": false,
      "mutation_class": "kb",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "kb unverify": {
      "surface": "both",
      "noun": "kb_write",
      "is_read_only": false,
      "mutation_class": "kb",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "query": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false,
      "app_params": {
        "graph_type": {
          "type": "string",
          "required": false,
          "description": "Set this whenever the result has a shape worth seeing: a trend over dates, a comparison across categories, a ranking. bar, line, area and pie need graph_x and graph_series - on a pie, graph_x names the slices and graph_series their values; kpi needs only graph_series. Omit only for a schema peek, a one-row lookup or a name list.",
          "reason": "The CLI prints a result to a terminal, which has no graph to configure; an in-app turn renders one beside the answer. Same capability (run a query), different mechanics (how the result is displayed), which is the asymmetry app_params exists for. Without it the app infers a type from column semantics alone and cannot know which of several valid readings the question asked for."
        },
        "graph_x": {
          "type": "string",
          "required": false,
          "description": "The column measured ACROSS. Name it exactly as the SELECT aliases it.",
          "reason": "Same surface asymmetry as graph_type. Inference picks the first temporal column and falls back to the first categorical one, which is right for a single grouped metric and wrong whenever a result carries more than one column the reader might have meant."
        },
        "graph_series": {
          "type": "string",
          "required": false,
          "description": "Measured columns, comma-separated, most important first, each named exactly as the SELECT aliases it. Prefer columns sharing a scale - a large one flattens a small one onto the baseline - and when only some share one, graph that subset rather than nothing.",
          "reason": "Same surface asymmetry as graph_type. This is the one the app cannot infer safely: it can compare magnitudes but not intent, so it guesses which numeric columns the question was about and silently drops the rest."
        },
        "graph_stack": {
          "type": "string",
          "required": false,
          "description": "Stack the series instead of placing them side by side: `normal` when the parts sum to a meaningful total, `percent` when the question is about each bar's MIX rather than its size. bar and area only. Omit for a plain side-by-side comparison.",
          "reason": "Same surface asymmetry as graph_type - a terminal has no graph to stack. Kept separate from graph_type rather than folded in as `stacked_bar`/`percent_bar` because stacking is orthogonal to the chart kind: the renderer takes it as StyleConfig.stacking on top of a normal bar or area, so folding it into the type enum would spell one kind and two modes as three kinds."
        }
      }
    },
    "metadata schemas": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "metadata tables": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "metadata columns": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "ds list": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "ds refresh-history": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "ds create": {
      "surface": "both",
      "is_read_only": false,
      "mutation_class": "data_source",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "ds refresh": {
      "surface": "both",
      "is_read_only": false,
      "mutation_class": "data_source",
      "requires_approval": true,
      "silent_retry_exempt": true
    },
    "ds verify": {
      "surface": "both",
      "is_read_only": false,
      "mutation_class": "data_source",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "ds update": {
      "surface": "both",
      "is_read_only": false,
      "mutation_class": "data_source",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "ds refresh-config": {
      "surface": "both",
      "is_read_only": false,
      "mutation_class": "data_source",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "ds delete": {
      "surface": "cli_only",
      "is_read_only": false,
      "mutation_class": "data_source",
      "requires_approval": true,
      "silent_retry_exempt": true,
      "reason": "Not a working verb on either surface. The CLI registers it only to name the boundary - deletion cascades into dashboards, KB tables and cached snapshots, so it runs from the Sources Hub where the blast radius is shown first. Generating it in-app would advertise a capability that always errors."
    },
    "ds move": {
      "surface": "cli_only",
      "is_read_only": false,
      "mutation_class": "data_source",
      "requires_approval": true,
      "silent_retry_exempt": true,
      "reason": "Same as `ds delete` - a Sources Hub action the CLI names but does not perform. A domain move re-homes every asset bound to the source, which is a decision the UI shows before it commits."
    },
    "dashboard list": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "dashboard get": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "dashboard get-html": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "dashboard list-entities": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "dashboard get-entity": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "dashboard check": {
      "surface": "cli_only",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false,
      "reason": "CLI-first posture: the alignment skill and /graphit:align are the consumers. The in-app agent already hears the identical signals on every write - the save response carries the same refusal bodies and entity_sql_warnings this verb reports - and it has no pre-save file to dry-run, so the read-projection adds nothing to an in-app turn today. Opting the app in is a separate owner decision, not a default."
    },
    "dashboard export": {
      "surface": "cli_only",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false,
      "reason": "The export IS the file. The route renders the dashboard and returns PNG/PDF bytes as an HTTP download, and an in-app turn has nowhere to put bytes: returning them would flood the context, and returning only metadata would claim a success whose single artifact was discarded - complete with a bell notification saying an export exists when none does. The app's own export UI is the in-app path, and it hands the file to the browser that asked for it."
    },
    "dashboard create": {
      "surface": "both",
      "is_read_only": false,
      "mutation_class": "dashboard",
      "requires_approval": true,
      "silent_retry_exempt": false
    },
    "dashboard update-html": {
      "surface": "both",
      "is_read_only": false,
      "mutation_class": "canvas",
      "requires_approval": false,
      "silent_retry_exempt": true,
      "app_params": {
        "content": {
          "type": "string",
          "required": true,
          "description": "The complete HTML document to write to the canvas - <!DOCTYPE html> through </html>, with all CSS and JS inline (the iframe blocks external resources).",
          "reason": "The CLI carries the page through the filesystem (--file/--stdin), which is the right ergonomic for a shell and impossible in an in-app turn: there is no local disk and no pipe, so the document has to ride the call. Named `content` and not `html` because `dashboard get --html` already claims that name as a boolean, and the noun's schema is flat."
        }
      }
    },
    "dashboard update-entity": {
      "surface": "both",
      "is_read_only": false,
      "mutation_class": "canvas",
      "requires_approval": false,
      "silent_retry_exempt": true,
      "app_params": {
        "content": {
          "type": "string",
          "required": true,
          "description": "The entity's INNER HTML only - do not include the outer element carrying data-graphit-id, which already exists (a duplicate id breaks later updates).",
          "reason": "Same as update-html: --file/--stdin are filesystem sources an in-app turn does not have. The description differs because this fragment is the entity's inner content rather than a whole document, and sending a whole document here is the mistake the CLI's own --file examples prevent by construction."
        }
      }
    },
    "dashboard edit": {
      "surface": "both",
      "is_read_only": false,
      "mutation_class": "dashboard",
      "requires_approval": true,
      "silent_retry_exempt": true
    },
    "dashboard publish": {
      "surface": "both",
      "is_read_only": false,
      "mutation_class": "dashboard",
      "requires_approval": true,
      "silent_retry_exempt": true
    },
    "dashboard release": {
      "surface": "both",
      "is_read_only": false,
      "mutation_class": "dashboard",
      "requires_approval": true,
      "silent_retry_exempt": true
    },
    "dashboard delete": {
      "surface": "both",
      "is_read_only": false,
      "mutation_class": "dashboard",
      "requires_approval": true,
      "silent_retry_exempt": true
    },
    "connector list": {
      "surface": "cli_only",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false,
      "reason": "Excluded with the rest of its group. No in-app agent action takes a connection id - `ds` and `query` resolve the connection server-side - so the read has no consumer, and an eighth noun for one unused list costs tool-window budget the seven earn."
    },
    "connector add snowflake-keypair": {
      "surface": "cli_only",
      "is_read_only": false,
      "mutation_class": "none",
      "requires_approval": true,
      "silent_retry_exempt": true,
      "reason": "Connection lifecycle. Adding a connection means handling the org's shared warehouse credential; that is an org-admin action performed in the web app, never something an agent turn assembles from chat."
    },
    "connector add bigquery-serviceaccount": {
      "surface": "cli_only",
      "is_read_only": false,
      "mutation_class": "none",
      "requires_approval": true,
      "silent_retry_exempt": true,
      "reason": "Connection lifecycle, and it takes a service-account key file. The agent must never be a path a credential travels along."
    },
    "connector test": {
      "surface": "cli_only",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false,
      "reason": "Connection lifecycle: `/test` exercises the org's shared credential and is org-admin gated for that reason. It belongs with the rest of its group in the web app."
    },
    "connector remove": {
      "surface": "cli_only",
      "is_read_only": false,
      "mutation_class": "none",
      "requires_approval": true,
      "silent_retry_exempt": true,
      "reason": "Connection lifecycle. Removing a connection breaks every data source built on it, org-wide."
    },
    "governance status": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "governance audit": {
      "surface": "both",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false
    },
    "team list": {
      "surface": "cli_only",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false,
      "reason": "Org-directory context, not analytics capability. Nothing in the in-app verb surface consumes a team id: dashboard sharing is done in the app's own UI, and `dashboard list --team` already covers the one analytics question teams answer. A noun for a single unconsumed read would spend tool-window budget for no capability."
    },
    "plugin status": {
      "surface": "cli_only",
      "is_read_only": true,
      "mutation_class": "none",
      "requires_approval": false,
      "silent_retry_exempt": false,
      "reason": "Reports the health of the plugin, npm package and skill files installed on the caller's machine. In-app there is no installation - the brain is composed from the image - so every field it returns is meaningless."
    },
    "setup": {
      "surface": "cli_only",
      "is_read_only": false,
      "mutation_class": "none",
      "requires_approval": true,
      "silent_retry_exempt": true,
      "reason": "Writes assistant files into the caller's filesystem for legacy editors. There is no filesystem to install into on the app surface."
    }
  },
  "identity_param_exemptions": {
    "governance audit": {
      "user": "Not caller identity. The audit log is already scoped to the caller's own org and authorized before it is read; --user narrows the rows returned within that scope, so it can never widen who the call runs as. Reviewed 2026-08-10."
    }
  }
}
