- Nav section switcher tabs are now "Library" (repository icon) and
"Playground" (beaker icon); browse page copy updated to match.
- Library rail content drops its collapsible heading; folders render directly
(section keeps its "Library" accessible name).
- Metabot button moves from the Playground rail into the app page header,
between search and the app switcher (hidden in iframes), and reserves its
32px slot while permissions load so the header controls don't shift.
- App header breadcrumbs render without icons (CollectionBreadcrumbs showIcons),
match the page's gutter/background via a published page frame
(setPageFrame replaces setPageBackground), and align vertically with the
nav rail's Official/Unofficial switcher.
- Metric header: no metric icon; main-app about page title uses h1 size
(PaneHeader titles gain a size option).
- Main-app metric pages render title and description read-only;
Data Studio metric pages opt into inline editing (isInlineEditable).
Metric page (main app):
- Pills under the chart reuse the Breakdowns logic: first 4 dimensions,
"Show more" adds 4, curated default stays visible. Breakdowns panel removed.
- Definition panel collapsed by default (DetailPanel gains collapsible /
defaultCollapsed).
- Dimensions and History panels shown only in Data Studio
(showManagementPanels).
Remote sync:
- Collections no longer swap to the synced_collection icon; they show their
own icon (CollectionCaption now honors collection.icon). Removes
isSyncedCollection plugin, REMOTE_SYNC_COLLECTION, and the isTenantUser
icon plumbing.
- Removes unsynced-changes dots from all nav collection trees, including the
CollectionsNavTree plugin and isCollectionDirty. Data Studio tab badges kept.
* GHY-3274: Skip the FieldValues scan on the initial sync of a Database
FieldValues are only created on demand, by the /values endpoints. A new Database has none. But the initial sync (sync-database!, which the database-create event runs) still ran the field-values phase. That phase did per-Field app-DB work for every Field: a count and a delete of expired advanced FieldValues, and an exists check before clearing each non-list Field. On a new Database none of that work can find anything.
sync-database! now reads the initial_sync_status of the Database from the app DB when it starts. When the initial sync is not complete, a :full sync runs the metadata and analyze phases and skips the field-values phase. The status comes from the app DB and not from the map that the caller passes, because the metadata phase marks the Database complete, and callers can hold a map from before the first sync. Later syncs, and the scheduled field-values task, run the scan as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* GHY-3274: Read initial_sync_status from the database map, not the app DB
sync-database! fetched the Database from the app DB to read its initial_sync_status. Production callers do not need that: the database-create event and advanced-config pass the Database they just created, the sample-data code creates or selects the Database first, and the notify API loads it per request. Only tests passed a map from before the first sync to a later sync. So sync-database! now reads the status from its argument, and saves one app-DB query per sync.
Three places in the tests re-synced with a stale with-temp map and expected the later sync to refresh FieldValues: one-off-dbs/insert-rows-and-sync!, scanning-test, and sync-and-find-values! in the warehouse-schema FieldValues tests. They now select the Database from the app DB before each later sync.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* GHY-3274: Stub the unique task list in the task filtering e2e test
The "filtering should work" test in tools.cy.spec.ts opens the task list with task=field values scanning, and checks that the "Filter by task" picker shows that value. The picker is a Select whose options come from GET /api/task/unique-tasks, and a Select shows an empty input when its value is not an option. The test stubbed GET /api/task but not unique-tasks, so it depended on real task history.
That real task history had a "field values scanning" row only because the initial sync of the Sample Database ran the FieldValues scan. This branch skips that scan on an initial sync, so the name is no longer in the list, and the picker was empty. The test now stubs unique-tasks with the task it filters on, in the same way that it already stubs the task rows.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Move Mongo convert-to-native coverage out of e2e
The Mongo e2e test asserted on a specific product row, but the converted
pipeline has no $sort, so row order depends on the Mongo container's
natural order and the row often fell outside the rendered grid.
Replace it with backend tests (no default $limit, nested query over a
converted native card runs, re-converts with its collection and without
Bson wrappers, and the re-converted query runs) and Jest tests for the
Mongo-specific labels and the collection handoff on convert.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Keep the positive collection case next to the negative one
The "no collection" case only proves something if the same fixture can
produce a collection; without the database feature flag, native extras
are silently dropped and the negative assertion passes vacuously.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Replace flaky metric-aggregation e2e test with a unit test
The CodeMirror value() e2e helper read the editor text once, so a read
that landed before the initial format settled failed without retrying.
It is now a Cypress query that re-reads on every assertion retry.
The notebook e2e test is replaced by an AggregateStep unit test. Writing
it exposed a blur during the initial format overwriting the formatted
source with the stale empty one; blur is now ignored while formatting.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Read CodeMirror text through invoke queries instead of a custom command
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Move command palette shortcut coverage down to RTL
Split the shortcuts monolith into a navigation test and a focus-suppression
test, and drop the e2e assertions a unit test can make deterministically:
shortcut modal toggling, c f / c d, the [ navbar toggle, admin tab digits,
the mobile search button, palette action presence, and the snowplow counts.
New unit specs drive the real shortcuts through kbar and assert the router,
the redux modal state and the tracking call. Admin settings links per role
were already covered by the PaletteResults suite, so those two e2e tests go
away as duplicates.
AdminNavbar's spec binds digits against a fixture tab list; the real list's
order, which those digits index into, is pinned next to getAdminPaths.
Dropped Meta+[ outright: toggle-navbar binds "[", and the modifier trigger
is $mod+. handled outside kbar, so the press matched nothing and the
assertion after it passed on the sidebar's pre-existing state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fold the documents-in-recents palette test into the main one
The test was kept separate because it needed the bleeding-edge token, which
enabled enough extra features to disturb the main palette test. Documents
moved to OSS: the token activation and the /api/ee/document endpoint went
with it, so the separation costs a cold app boot and buys nothing.
The document is created before the dashboard so the dashboard stays the most
recent entry and the existing selection assertion holds.
Also pin the personal-collection name on the read path in Clojure, which is
the one thing the e2e assertion covers that no backend test did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fold the API-order palette test into the main one
Both tests are "real search results render correctly in the palette" and
each paid its own cold app boot. The order check moves into the query block
of the main test, right after the other search assertions.
The searchData alias is registered immediately before its query rather than
at the top of the test: cy.get yields the last matching request, and the
earlier searches in the merged test would shadow it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Anchor the shortcut navigation test on rendered pages
cy.location resolves when a route transition starts, not when it finishes, so
chaining chords on URL assertions alone lets the next chord land mid-mount
and be dropped. Each hop now waits for content on the destination page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Sync the dashboard bookmark shortcut on its API writes
The o shortcut decides between add and remove from the refetched bookmark
list, so pressing it twice in a row can toggle against stale state and leave
the dashboard bookmarked. Each press now waits for its write, and the
positive assertion between them proves the list refetched before the second.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Set up the palette permissions test through the API
The test needs a non-admin with settings, monitoring, table-metadata and
database-management access; how those grants get made is not what it covers.
Driving the two permission graphs directly replaces a cold boot into the
admin permissions screen, ten UI interactions and two save round-trips.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Wait for the bookmark toggle before the dashboard shortcut
visitDashboard resolves on the dashcard query responses, not on render, so
the o keystroke could land before DashboardBookmark mounted and registered
the shortcut — no request, no toggle. Anchor on the button that component
renders, which is the same thing that owns the shortcut.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Wait for the app bar before opening the palette after sign-in
The palette registers its toggle only once its effect sees the logged-in
user, and the home greeting renders independently of the app bar, so the
ctrl+k could land while the palette was still disabled and be swallowed.
Anchor on the search button, which the same shell renders.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Tighten restricted-shortcut and admin-path filtering tests
Assert the pathname stays put right after a restricted shortcut, and cover
the admin paths reducer dropping paths for a non-admin.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Mount the admin app reducer at its typed store path in tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Replace flaky help text docs link e2e test with a unit test
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Assert the help text docs link click is not cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Replace upload-permission e2e tests with unit coverage
The "permissions" e2e block restored the postgres-12 snapshot only to
assert upload button visibility, which is covered by backend
can_upload tests and CollectionHeader unit tests. Add the missing
unit case: button hidden when uploads are enabled but canUpload is
false.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Add a missing backend coverage
* Make upload-permission unit tests assert the right cause
The sandboxed can_upload test enabled uploads on the original database
while with-gtaps-for-user! queries a temporary copy, so can_upload was
false because uploads were disabled. Enable uploads inside the gtaps
block and sanity-check with an admin on the same database.
Add CollectionContent cases driving the upload button from the
database's can_upload response.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
While the connection query was loading, its missing status was read as
"not-connected", so connected admins briefly saw the connect modal. The
modal now renders nothing until the status arrives.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(ui): add Accordion Storybook story and Loki filter
* feat(ui): restyle Accordion from Figma
* fix(Accordion): keep theme selectors one class deep so call-site overrides still win
* refactor: drop Accordion call-site overrides now covered by the theme
* test(ui): add Loki reference for Accordion Overview story
* fix(Accordion): hide the default chevron from assistive tech
* test: update DataComplexityCards snapshot for the Accordion separated default
* fix(RemoteSyncDependencyModal): drop overrides the new Accordion theme makes redundant, keep control transparent
* fix(RemoteSyncDependencyModal): let the Accordion theme space the rows
* fix(Accordion): keep icon grey on disabled hover, honour the radius prop
* test(ui): add disabled hover row to the Accordion Overview story
* Update Loki Snapshots
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
* Refactor metabot LLM provider adapters: centralize shared logic in `adapter.clj` while simplifying implementations.
* Clarify error message for unsupported adapter models
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Rework auth, add some types
* drop span cusotmization
* drop extract and headers from CatalogOpts
* fix counts
* revert adapter/context-window-fn
* more tests for headers merging
* normalize-known-model fix
* doc fix
* more strict catalog name resolution
* simplify
* google coverage v1
* registry v1
* registry v2
* simplify
* align bedroc, drop dead ai-proxy?
* fix docstring
* improve tests
* rename listing to model-listing
* rename ai-proxy?
* rename keywords
* improve catalog error handling
* drop redundant content type
* move google auth inside request span
* route vllm's lsit-models through request!
* Update src/metabase/metabot/self/registry.clj
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
* remove history from docs and comments
* Update src/metabase/metabot/self/registry.clj
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
* fix var refs in optional/required
* refactor model context-window handling to use registry function
* migrate span attributes to nested :attributes map in adapter and update tests
* standardize function schemas with `mu/defn` and introduce `ResolvedRef` for adapter inputs
* remove redundant comment block in adapter test
* adjust schemas for closed maps enforcment
* reject unsupported AI proxy usage in Azure adapter and expand test coverage
* refactor registry-test to dynamically derive providers and improve adapter validation
* bring back old logging behavior
* stringify `:family` span attribute across adapters and update tests
* fix vllm test name and bedrock reference
* standardize `list-models` function schemas across adapters with `mu/defn` and update related tests
* add schema conformity test for `:supported-models` across adapters
* fix Auth schema
* Get back old tru errors
* Bake-in content-type header
* Expand `ModelListing` schema to include `learned-config` with additional closed map fields for connection reasoning.
* Remove `^:parallel` marker from test to disable parallel execution
* Update src/metabase/metabot/self/claude.clj
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
* Rename `:learned-config` to `:connection-info`
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
GHY-4651: Let the metering query use the query_execution started_at index
SendMeteringEvents runs every 15 minutes and on every boot. It counts yesterday's query executions with WHERE CAST(started_at AS DATE) = CAST(? AS DATE). Casting the column hides it from idx_query_execution_started_at, so the app DB scans the whole query_execution table. On busy instances that table holds millions of rows.
The query now filters with a half-open range, started_at >= yesterday 00:00 UTC AND started_at < today 00:00 UTC. The DB can answer that range from the index. The db function becomes query-execution-statistics-between, and query-execution-last-utc-day computes the UTC day bounds. The docstring already said UTC day; the old cast used the DB session time zone instead.
The new test in token-check-test runs metering-stats, captures the SQL it sends for query_execution, runs EXPLAIN on the H2 app DB, and checks that the plan uses the started_at index. A second test pins the day boundaries: yesterday 00:00Z is in, today 00:00Z is out.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Always show the Metabot illustrations toggle
* Only mention the chat sidebar in the illustrations label and docs
* Test the illustrations toggle with the default icon
GHY-4323: Cap related-table ids before the lookup in get_fields
related-tables-by-requested-table collected every FK target of every requested table into one IN clause, then applied the 50-table cap to the result. A table with many FK columns made the lookup ask for tables that the response then dropped. The cap now applies to each table's targets before the lookup, so the IN holds at most 50 ids per requested table.
The :target hydration in the metadata fetch already drops unreadable targets, so only inactive or unbrowsable targets can now shrink the surfaced list below 50.
The other half of the ticket, one permission query per related table, is already fixed. Since 6908f26b76, field/readable-fields-only primes the table perms cache for every FK target while it hydrates :target, and the related tables are exactly those targets. The new API test covers it: as a non-admin with per-table grants and 60 related tables, the call succeeds under the N+1 guard, and it fails with that prime removed.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Take host-part credentials off the pgvector URL too
Credentials written before the host (user:pass@host) stayed on the URL.
pgjdbc reads that form as part of the hostname, so the password showed up
in its "unknown host" error and its DEBUG line.
- Move them into the credentials map, whatever the scheme, splitting at
the host part's last @, with %-escapes decoded and + kept as is. Setting
one both there and as a param fails at startup.
- Refuse any other unencoded @, which is what an unencoded / or ? in the
password leaves behind, with a message that quotes none of the URL.
- Rethrow a malformed %-escape without the decoder's message, which quotes
part of the value.
* Accept an unencoded @ in pgvector URL query values
pgjdbc decodes query values and gives @ no special meaning, so Azure's
documented user=name@server must keep working. Only a host part with no
path after it, the shape an unencoded ? in a password leaves, still
refuses an @ in the query.
Stop ErrorOverview recovery test from racing the search debounce
Fake timers skip the 500ms debounce so it no longer eats into findBy's
1s budget, and the lookup is scoped to the row so TreeTable's transient
off-screen measurement copy of the cell can't produce a duplicate match.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pivot a month-bucketed date breakout under a German locale and assert the
subtotal label keeps the localized month. Formatting the value a second time
re-parses "Mai 2026" and yields "Januar 2026", which is the regression the
assertion pins.
Drop the Cypress reproduction: it rendered a pivot of Product Title x month x
User ID to check the same label, and was racing the render.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Lint values, not call style, and write the value-binding rubric down
Two changes to the value-binding sweep (GHY-4477), both correcting guidance that
earlier namespace PRs were written against.
Stop asking for query maps. The linter's `lint-kv-args!` check asked every
`:column value` call to be rewritten as a query map, on the premise that a query
map is the shape the HugSQL extraction in step 2 reads off. That premise does not
hold. Both extraction spikes show it: the glossary spike (#82530) extracted
cleanly *from* kv-args, and the SSO spike (#82587) still needed substantial
rewrites *from* query maps -- a `SELECT 1 ... LIMIT 1` reshape and empty-collection
sentinels. Extraction is semantic. It turns conditional clauses into flags, picks
portable SQL, and discards the HoneySQL shape entirely (`h2x/like-substring`'s
`[:escape ...]` form becomes plain `ESCAPE '!'` text). A query map is not closer
to the target, just a different thing to throw away.
Converting also introduced a bug class. Toucan applies a model's `deftransforms`
`:in` fn only in kv-arg position -- it hooks `query/apply-kv-arg`, and a raw
`{:where ...}` map never passes through there (toucan2 calls HoneySQL "an outlet
to bypass type transforms"). So moving a keyword value into a where map drops the
transform, and HoneySQL formats a keyword in a value slot as an *identifier*:
(t2/select :model/SearchIndexMetadata :engine :appdb)
;; => ["... WHERE ENGINE = ?" "appdb"] transform ran
(t2/select :model/SearchIndexMetadata {:where [:= :engine :appdb]})
;; => ["... WHERE ENGINE = \"APPDB\""] transform skipped, broken
That shipped twice on sweep branches before being caught (`sync/db.clj`'s
`:semantic_type :type/Name`, `collections/db.clj`'s `:type :api-key`). It compiles,
runs, and returns wrong rows. Nothing static can check the hand-written
post-transform value, so the check that produced these conversions is removed
rather than narrowed.
Lint kv-arg values instead. Removing the style check on its own would have left a
real gap: `value-nodes` only understood query-map clause shapes, so a value passed
as a kv-arg pair was never examined for a missing marker. `lint-unmarked-values!`
now walks kv-arg pairs too, starting after the `:model/...` keyword rather than at
a fixed offset (several fns take an argument before the model, e.g.
`(t2/select-one-fn :value :model/Setting :key k)`). Inside a kv-arg the column
comes from the pair key, so every argument of an operator form is a value --
`:id [:in ids]` checks `ids` -- unlike a query-map clause where the first argument
is the column.
Net effect on findings: call style is no longer reported; an unmarked value is
reported whichever style it was written in. Verified against
`settings/db.clj` (kv-arg style, opted in temporarily): both `setting-key` reads
are now flagged, and the `value` in `update!`'s changes map is correctly not.
`content_translation/db.clj`, the merged worked example, stays at zero.
Write the rubric down. docs/developers-guide/value-binding-rubric.md records every
decision the sweep needs, so the remaining namespace PRs stop re-litigating them in
review. Seven ordered rules, each with an example; the two that were previously
implicit are now explicit -- never mark a literal (it cannot carry a request value,
and in step 2 it becomes a HugSQL hole every caller fills with the same constant),
and leave a possibly-empty collection unmarked (Toucan rewrites `[:in col []]` to
`false` inside the step the marker wraps). It also records the `t2/update!` shapes
that work, what `value_guard` refuses for you, and the known limits the lint cannot
see -- so "zero findings" is replaced by "every finding explained by a rule".
* Frame the call-style rules as scaffolding that HugSQL extraction retires
A HugSQL query fn takes one map and has no override surface, so the
kv-arg-vs-query-map question -- and the transform-position trap that comes with
it -- does not exist for an extracted namespace. Say so, rather than leaving
these rules to read as permanent house style.
* See value slots inside conditionally built clauses
value-nodes stopped at a list node, so any value a when/if/cond-> built was
invisible to the unmarked-value check -- a false negative in a security lint,
and a common shape: permissions/db.clj and collections/db.clj both assemble
where clauses that way.
(t2/select :model/Setting {:where [:and (when k [:= :key k]) [:= :o o]]})
;; o was flagged, k was not
Walking list-node children finds it. This surfaces real unbound values in four
namespaces (dependencies 12->17, sync 7->10, collections 5->7, search 5->6);
they are bound in the namespace PRs stacked on this branch.
* Correct the transform section: the call style breaks it, not the marker
The section implied that marking a value on a transformed column is what
produces the identifier comparison. It is not. value_guard lifts the
[:auto/param column v] 3-arity from inside apply-kv-arg, so a MARKED kv-arg
still runs the transform -- verified against :model/SearchIndexMetadata, the
bare and marked kv-arg forms compile to byte-identical SQL.
Only the where-map form skips the transform. So a marker on a transformed
column in kv-arg position is redundant rather than dangerous, and the reason to
leave it off is that the transform already binds the value.
Caught by a reviewer that compiled all three forms rather than taking the
section at its word.
* Rewrite the transform and written-value rules as single explanations
The transform correction was bolted onto the end of the section, so it stated
the rule, then contradicted its own framing, and showed the same example twice.
Rewritten as one block that shows all three forms together -- kv-arg bare,
kv-arg marked, query map -- so the variable is visible: the call style is what
takes a value off the transform path, not the marker. Adds the converse case a
sweeper actually hits: a call that was ALREADY a query map on master must carry
the post-transform value written out.
Rule 1's example showed a t2/update! CONDITIONS map while the prose was about
the changes map, which is the confusion the rule exists to prevent. Replaced
with a real settings/db.clj call where the condition is marked and the changes
map is bare, plus an explicit note that only the trailing changes map is exempt.
Also corrects the stated mechanism. The reason not to mark a written value is
that the column's `:in` transform runs on the marker and persists it as data --
mi/transform-encrypted-json is an :in transform, so an encrypted column
encrypts the marker. The earlier text attributed this to a
define-before-insert hook on :model/Setting, which does not exist; Setting's
encryption is application code plus a column transform.
* Refuse a marker outside a value slot, and see two more shapes
Three findings from review, all verified against a running app DB.
The guard was silent on the one case it most needed to catch. `auto-param`
lifts a marker wherever it sits, so one written in a `:select`, `:from`,
`:order-by` or `:group-by` clause became a bound parameter in a slot HoneySQL
wanted an identifier for -- compiling to the literal identifier `PARAM` and
discarding the value, with nothing downstream able to notice:
(t2/query {:select [[:auto/param "name"]] :from [:setting]})
;; => ["SELECT \"PARAM\" AS \"P3R1MO8JB3WD0J\" FROM \"SETTING\""]
`check-marker-placement!` now refuses that as `::marker-outside-value-slot`. It
stops at a nested query map, because a subquery's own clauses are checked on
their own terms -- `t2/exists?` wraps the query in `:select [[[:exists {...}]]]`
whose inner `:where` is a real value slot, and scanning the outer `:select`
wholesale rejected it. A join's ON condition is likewise a value slot, so only
the table halves of a join clause are checked.
The lint could not see a conditions map. `(t2/update! model {:col v} changes)`
filters on `v`, but it is not a `:where` clause, so neither walker descended it
-- and the rubric recommends that exact shape, so a namespace written that way
passed vacuously. Now walked for `update!`, `update-or-insert!` and `delete!`,
taking only the map immediately after the model (a later map is the changes map,
exempt by rule 1). An operator form there holds only values, since the column
comes from the map key.
The lint could not see into a subquery. The operator branch returned its
arguments without descending, so `[:in :id {:select ... :where [:= :z z]}]` left
`z` unchecked. It now keeps each value and descends into it; descending alone
would have dropped the plain-token case.
Rubric corrections from the same review, each verified rather than reasoned:
rule 2's failure mode (marking a column keeps the comparison and binds the
column NAME, it does not drop it), rule 5 (`:not-in` gets no empty-collection
rewrite, so an empty one emits invalid `NOT IN ()` marked or not; rule 4 wins
for collections of ids; a non-empty proof must be local), rule 4 (`long`
truncates a non-integral number rather than throwing), rule 1 (a written value
is not the injection shape this project targets, rather than never structural),
rule 7 (booleans from outside are values too), the transform mechanism
(`apply-kv-arg` maps the transform over an operator form's tail, which is why a
marked kv-arg keeps it -- nothing to do with `value_guard`), and a new
false-negatives subsection, because the rubric previously described the lint as
only over-flagging.
* Scan subqueries for misplaced markers, and allow one inside an expression
Two holes in check-marker-placement!, both verified by compiling the form.
It only scanned the top-level map, so the silent case survived one level down:
{:select [[[:exists ^:allow-subquery
{:select [[:auto/param "name"]] :from [:setting]}] :e]]}
;; => SELECT EXISTS (SELECT "PARAM" AS "PACGBLW31WM3R" FROM "SETTING")
No throw, value dropped -- exactly what the check exists to prevent. The
docstring, the test, and the rubric all claimed a subquery was "checked on its
own terms", which was not implemented; nothing re-ran the clause scan on a
nested map. warehouse_schema/db.clj:317 already uses ^:allow-subquery with
:exists, so the shape is live. It now walks every nested map and scans each
one's clauses.
It also refused legitimate value slots. A comparison inside an identifier
clause is a real value slot, and both of these threw at compile:
{:select [[[:= :engine [:auto/param "h2"]] :is_match]]}
{:order-by [[[:case [:= :a [:auto/param 1]] 1 :else 2] :asc]]}
So a converted namespace would break the moment it grew a computed projection
or a CASE sort key. `:join` already needed a carve-out for its ON condition;
this is the same need, and the file already recognizes the shape via
`operator-form?`, so the scan descends past a keyword-headed form.
Pre-existing failures in metabase.app-db.custom-migrations-test are unchanged:
24 on origin/ghy-4477-rubric-foundation, 24 with these fixes.
* Classify identifier slots positionally, and close four lint blind spots
Findings from four independent reviews of this PR, each verified by compiling
the form or by linting a probe rather than by reasoning.
The guard missed its own target case. `marker-in-identifier-position?` decided
what was a value by head shape, but HoneySQL tells `[expr alias]` from
`[:= a v]` POSITIONALLY. So `[:t [:auto/param "al"]]` read as though `:t`
headed an operator form, and the alias slot -- the shape the check exists to
catch -- passed:
{:from [[:t [:auto/param "al"]]]} => "SELECT * FROM t AS param k"
{:select [[:a [:auto/param "al"]]]} => "SELECT a AS param k FROM t"
Now an entry is destructured as `[expr & aliases]`: index 0 may be an
expression and is descended, everything after it is a name and is refused.
`:order-by` and `:group-by` are no longer refused. HoneySQL binds a param in
both (`ORDER BY ?`), so a marker there is a pointless no-op, not a dropped
value -- and the test asserted the throw as correct, pinning a premise HoneySQL
contradicts. Refusing it would have turned a harmless mistake into a
production exception.
`:cross-join` no longer takes the alternating path. It has no ON condition, so
`take-nth 2` skipped every odd-indexed table; a marker there was missed.
Only the four join clauses that actually pair a table with a condition use it.
Added `:using`, `:create-table`, `:drop-table`, `:truncate`, `:partition-by`,
`:window`, `:select-top`.
The scan now short-circuits on `contains-marker?`. It runs on every app-DB
query at compile, and almost none carry a marker. 18.0us -> 13.7us on a
join-heavy query with no marker.
Four lint blind spots, all with live call sites:
`[:!= :col v]` / `[:<> :col v]` were unchecked -- `value-operators` listed
only `not=`. collections/db.clj:283 passes a request `user-id` through `:!=`
in a namespace a stacked PR reports as converted. Added `!=`, `<>`,
`is-distinct-from`, `is-not-distinct-from`, `not-between`.
A value inside a function-call form -- `[:= :k [:lower v]]`, which compiles
to `LOWER(?)` -- was dropped by the `:else` branch, whose token children
return nothing on their own. Live in audit_app/db.clj. Same fix reaches a
literal collection: `[:in :k [a b]]` now checks both elements.
A two-arity `t2/update!` had its CHANGES map read as conditions. Toucan's
arglist ends in the changes map, so a lone trailing map is changes --
remote_sync/db.clj's mark-all-rsos-synced! was flagged for a written value,
contradicting rubric rule 1. `delete!` has no changes map, so its lone map
is still conditions.
A query map passed where conditions go was walked twice, reporting every
value twice. Live in mcp/db.clj, metabot/db.clj, testing_api/db.clj.
Findings are now deduped by source position, which also covers a multi-arg
operator in a kv-arg: `:d [:between lo hi]` reported `hi` twice, because
`value-nodes` misreads the vector as a clause while `kv-arg-value-nodes` reads
it correctly. Deduping is robust where keeping the walkers disjoint is not.
Also: the marker payload is no longer descended (a marked value was reported as
though bare once the `:else` branch started yielding children), `ex-data`
carries the query so a dynamically-built one can be traced, and `args` is no
longer shadowed inside the conditions-map walker.
* Bring the rubric's guard and false-negative sections up to date
The guard section described the head-shape classifier and claimed :order-by /
:group-by were refused; both changed. The false-negative list still carried the
conditions-map, function-call-form, literal-collection, operator-alias and
subquery shapes as open, which are now covered -- and was missing the two that
remain open, a positional pk and a non-literal model.
* Drop the unused clojure.string require and the bang from check-marker-placement
Two clj-kondo failures on the previous push, both mine.
`clojure.string` became unused when the join carve-out stopped matching clause
names by suffix.
`check-marker-placement!` only throws -- it mutates nothing -- but the bang made
`:metabase/validate-deftest` read every call in a `^:parallel` test as a
destructive operation. Renamed rather than whitelisted, since the name was
wrong: the convention marks state change, and this is a predicate that refuses.
* Don't ask for a marker where the guard refuses one
`value-nodes` walked every clause of a query map, and the `:else` vector branch
yields each child as a candidate value. Together those reported symbols sitting
in clauses that name columns and tables, and the message always says the same
thing: write it as `[:auto/param x]`. In a `:select` or a `:from` that advice is
not merely noise -- `check-marker-placement` in this same PR throws
`::marker-outside-value-slot` when you follow it, so the two halves of the
mechanism contradicted each other.
A query map's clause values are now split. A clause that names things
contributes only what is nested inside it: a subquery's own clauses, and the
arguments of a keyword-headed comparison. Everything else is unchanged.
Measured over `src/metabase` and `enterprise/backend/src`, with the linter
forced on for every namespace: 1981 warnings to 1963. Eighteen removed, none
added. All eighteen are identifier slots -- `output-table-id`,
`table-wildcard`, `sort-direction`, `order-column`, `id-column`, `cast-form`,
and a `union-query` alias. Every value-carrying shape survives: a computed
projection, a `CASE` sort key, a subquery nested in `:from`, a subquery in an
`:in`, a plain where clause, a kv-arg.
`:order-by` and `:group-by` are in the set even though the guard deliberately
allows a marker there. A marker in `:order-by` compiles to `ORDER BY ? ASC`,
which loses the ordering, so asking for one is still wrong advice.
Two related shapes stay open, both false positives that ask for a marker in a
position that holds no value:
- A clause built with `assoc` or `cond->` is not a map in the source text, so
the walker still reads it as a plain vector. `dependencies/db.clj:492` keeps
its three `sort-direction` warnings.
- A binding vector inside a `let` or a `for` is walked like a collection
literal, so the binding names are reported.
Neither is urgent: the linter is opt-in per namespace, the noise surfaces one
namespace at a time during a conversion that reads every warning, and the
rubric's gate is already that a rule or a limit explains each one.
* Allow a marker at index 0 of an expression entry
In :select, :select-distinct, :select-top, :returning and :partition-by,
index 0 of an [expr alias] entry is an expression, and HoneySQL binds a
param there (SELECT ? AS a). The guard refused it as an identifier.
The table clauses also bind a param at index 0 (FROM ? AS a), but a
table cannot be a parameter, so the guard still refuses a marker there.
* Refuse a marker that is the whole identifier clause value
When the value of a clause was itself a marker, as in :from [:auto/param "t"],
the guard split it into :auto/param and the payload. Neither is a marker, so
the guard passed it, and it compiled to FROM param, k with the value dropped.
Treat a marker value as a single entry, ahead of the join branch too.
* Remove the value-binding rubric from the repo
A reviewer asked that it not live in the repo.
* Give the lint the clause context it was guessing at
Two fixes, both places where the lint's judgement of a slot did not match
`value-guard`'s.
**Split the expression-entry clauses.** `identifier-clauses` was one flat set,
so the lint stayed silent about every position in `:select`, `:returning` and
friends. The guard no longer agrees: its `expression-entry-clauses` treats index
0 of an `[expr alias]` entry as a value slot, and `{:select [[[:auto/param "a"]
:k]]}` compiles to `SELECT ? AS "K"` with the value bound. The silence there was
a false negative -- a request value in a computed projection reached the
statement text with nothing to report it.
Index 0 of an entry is now a value slot. Everything after it is a name, and a
bare entry is a name, which matches what the guard refuses:
{:select [[:auto/param "a"]]} refused (bare entry)
{:select [[:key [:auto/param "a"]]]} refused (alias)
{:from [[[:auto/param "t"] :x]]} refused (table)
{:select [[[:auto/param "a"] :k]]} binds SELECT ? AS "K"
**Carry clause context through `assoc`.** A clause written outside a map literal
reached the walker through the list branch, which keeps no record of anything,
so the name-slot check never ran. The lint then asked for a marker on a column
name and on a sort direction. Both fixes it named break the query, and one
breaks it in silence: marking the direction throws a ClassCastException, and
marking the column compiles to `ORDER BY ?` and loses the ordering with nothing
at runtime to catch it.
A clause key inside an `assoc` call now routes its value the way a key in a map
literal does. `cond->` is covered because the walk descends into the nested
`assoc`.
Measured over `src/metabase` and `enterprise/backend/src`, with the linter
forced on for every namespace: 1963 warnings to 1954.
Fifteen removed, all of them the `assoc` case: `sort-direction` and
`sort-column` in task_history/db.clj:174, cache/db.clj:102 and
dependencies/db.clj:492, `column` in queries/db.clj:53, models/db.clj:152 and
:171 and documents/db.clj:58, and `table-level-case` in permissions/db.clj:89.
Six added, all of them expression entries the flat set had been hiding:
`output-table-id` in data_studio/db.clj (three sites), `cast-form` in
app_db/db.clj:20, `domain-expr` in internal_stats/db.clj:206 and
`admin-group-id` in permissions/db.clj:681. Each holds a column reference today,
so each is a rule 2 finding to record rather than a value to mark -- but the
lint can no longer tell, which is the point.
Still open, and not addressed here: a binding vector inside a `fn` or a `let` is
walked like a literal collection, so its names are reported; `:order-by` and
`:group-by` are absent from the guard's set, where a bare marker entry drops the
value; and the guard's DDL keys are still missing from the lint's set.
* Give the rubric comments their own reasons
The rubric left the repo, so five comments cite a file a reader cannot open.
"Rubric rule 1" was a useful short form while it was here; now it asks the
reader to go looking in the git history.
Each one now states the reason instead of pointing at it. A written value is
never marked because the column's `:in` transform runs on the marker itself and
stores it as data, and the conditions-map shape is checked because the sweep
leaves it in place.
Comments only. No behaviour changes.
---------
Co-authored-by: Eric Normand <ericwnormand@gmail.com>
GHY-4213: Infer untyped model metadata after a metadata sync
A GUI model that is deserialized (for example by a Remote Sync import) before the target has synced its tables cannot infer its result metadata. The import then stores only the exported model overrides, with base_type type/* and no field ids, and nothing revisits it. Filters on those columns offer only "Is empty" and "Not empty".
After each metadata sync of a database, look at the unarchived MBQL models on that database. For each model whose stored metadata has no known base type, infer the metadata again, keeping the model overrides. A model that still cannot be inferred is left as it is. The update goes straight to the app DB, so it publishes no card-update event and does not mark remote-synced content dirty.
Tests: an API-level test imports a GUI model through POST /api/ee/remote-sync/import before its table is synced, runs POST /api/database/:id/sync_schema, and checks the column types in GET /api/card/:id. Contract tests cover untyped-metadata? and backfill-untyped-model-metadata!.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* GHY-4399: Cascade collection archive to nested content in the remote sync ledger
Archiving a collection archives its whole subtree in bulk SQL and publishes one collection-update event for the top collection only. The remote sync collection handler marked only that collection as delete, so the next push removed its own file and left every nested collection, card, and other item in the repo for good.
The handler now brings the ledger rows of the subtree in line with each entity archived state when the collection is archived, or when its own row was a pending delete (it was unarchived before a push). Rows of archived entities become delete. Delete rows of entities no longer archived become update, so restoring a folder before a push does not delete its files.
subtree-expr matched descendants by the prefix /<id>/, which is correct only for root collections. It now uses the location the children have (location + id + /), which is the same string for roots and is also correct for nested collections.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* GHY-4399: Cascade collection archive to transforms in the remote sync ledger
The cascade marked a subtree row as delete only when the entity itself was archived. Transforms have no archived column, so a transform in an archived Transforms folder stayed synced, and the next incremental push left its file on the remote. This is the first repro in the ticket.
A row now also counts as archived when the collection it is in is archived. Unarchiving restores it to update when neither the entity nor its collection is archived and the entity still exists.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* GHY-4554: Mark untrusted data in MCP v2 tool results with an unforgeable boundary
Tool results put instance data (card names, warehouse rows, column comments) next to server prose, with nothing to mark where the data stops. A planted instruction in a name could pass for an instruction from Metabase.
message/data holds data from outside the server. render writes it as JSON between <data boundary="..."> and </data boundary="..."> lines that carry a random 32-hex-digit value, which the data cannot predict and so cannot close early. The initialize instructions explain the convention in one line.
message/json-text encodes JSON and then writes invisible, line-breaking, private-use, and unassigned code points (Cc, Cf, Zl, Zp, Co, Cs, Cn) as \uXXXX escapes. Decoded values do not change. The transport encodes JSON-RPC responses with it, so structuredContent is escaped on the wire too.
Every (message/raw (json/encode ...)) site and common/success-content now use message/data. Tool tests read the text through test-util/strip-data-boundary.
* GHY-4554: Make question_write and transform_write text-only
Both tools sent their payload as structuredContent as well as in the text block. Claude Code shows the model structuredContent in place of the text block, so the model saw the payload with no data boundary and with invisible characters raw. No client reads their structuredContent, so they now answer text-only.
* GHY-4554: Say to keep untrusted content out of structuredContent
A client may show the model structuredContent in place of the text block, and there the data has no boundary and its invisible characters decode raw.
* GHY-4554: Put glossary lookup results inside a data boundary
Glossary terms and definitions are user-written free text. The glossary() listing already put them inside the per-response data boundary, but glossary(term) quoted them into server prose. The lookup now returns its matches in the same data envelope as the listing, so a planted closing tag or instruction reads as data.
* GHY-4554: Make strip-data-boundary strict
The test helper returned text with no data boundary unchanged, so a tool that stopped wrapping its result still passed its suite. It now throws, and the suites that read error results through the same helper read those raw. With the renderer changed to emit bare JSON, every MCP v2 tool suite now fails.
* GHY-4554: Extend the never-follow rule to quoted values
Server prose quotes every value it did not write, so a planted instruction can reach the model inside a quoted value as well as inside a data boundary. The initialize instructions now name both.
* GHY-4554: Name the sliced table by id in the get_fields paging line
The paging line put the warehouse-controlled table name in server prose, outside the data boundary, and printed null when a fields projection dropped the name and id. It now names the table by the id from the source row and gives the exact next call, so the name stays inside the data boundary and the agent always knows which table to page.
GHY-4636: Let remote-sync import run without expected_branch
The mb CLI calls POST /api/ee/remote-sync/import with only a branch. GHY-4019 made expected_branch a required key, so every CLI import failed schema validation with 'expected_branch: missing required key'.
expected_branch is a compare-and-swap guard against a stale browser tab. A headless client has no cached view that can go stale, so the key is now optional. The endpoint runs the guard only when the client sends the key. The web UI still sends it, so its behavior does not change.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Give on-demand test profiling a single entry point
backend.yml, app-db.yml, drivers.yml and semantic-search.yml each carried their
own workflow_dispatch block, their own PROFILE_TESTS/ONLY_TESTS env block, and a
selector input, all to support the same thing: run one leg of one suite on
demand, under a profiler, optionally narrowed to named tests.
profile.backend.yml now owns the only workflow_dispatch and calls the four
unchanged. They declare nothing for this feature: inside a called workflow
`github.event` is the caller's payload, so they read `target` and `tests` off
`github.event.inputs` directly. On a normal CI run those are absent and render
empty, so every gate's `== ''` clause holds and each suite behaves as before.
One `target` dropdown rather than a suite plus a leg keeps the gates at their
existing two clauses, and disambiguates `postgres`, which names a leg in three
suites.
Also fixes two ways a profiled run produced no flamegraph:
- test-driver built `:profile-tests` into BASE, but BASE is used only on the
ONLY_TESTS branch. Profiling without a test selection ran unprofiled while
still reporting profiled=true. The alias now goes on ALIASES too, which the
other three branches use.
- The multi-JVM path runs each JVM from a sibling directory, so the profiler's
relative output dir resolves per JVM. merge_outputs copied back junit and
logs but not the flamegraphs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Collect profile output from multi-JVM test runs
collect-profile predates the multi-JVM test path and reads only the layout a
single-JVM run produces. A partitioned run nests its junit XML one directory per
partition under target/junit, and merges one logs/test-log-p<index>.json per
JVM, so the non-recursive `target/junit/*.xml` glob and the exact
`logs/test-log.json` path matched nothing: an empty wall-time summary, and an
artifact holding the flamegraphs alone.
The two summary greps become `grep -r --include='*.xml'`, which reads both
layouts without needing globstar, and the upload paths become globs matching
test-driver's own failure-log upload.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Simplify inputs
* Use `test` namespace
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* initial commit
* Use CTEs for every multi-stage Postgres query
Drop the "standalone statement" gate and the native/persisted-stage and
join-source fallbacks. Postgres accepts WITH everywhere we embed a compiled
query (JOIN parens, CTE bodies, card-reference subqueries, CREATE TABLE AS,
INSERT INTO), so the only remaining condition is the driver opting in and
the query having more than one stage.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix changelog
* simplify with mapv
* comments
* Build CTE tests with lib and assert results only
Rewrite the four multi-stage tests added on this branch to build their
queries with lib/query and a metadata provider instead of mt/mbql-query,
and drop the compiled-SQL string assertions so they only check that each
query runs on Postgres and returns the expected rows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* trim comments
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
ClickHouse denies SELECT on system.data_skipping_indices, so index verification failed for every request (the index itself was created). fetch-table-indexes now reads one SHOW CREATE TABLE and parses its INDEX and ORDER BY lines. A failed warehouse read now carries the driver's message on the request and as warehouse_error on the indexes list, with an alert on the indexes page.
Remote sync: full export when a synced collection is renamed or moved (GHY-4642)
A collection path is also the directory of its contents. The incremental export wrote the renamed collection file and deleted the old one, but left every descendant file under the old directory, and kept their stale file_path values. Later incremental pushes did not repair it.
An update to a Collection whose stored path differs from its new path now falls back to a full export, which rewrites every path and records each file_path. This matches the import side, which already does a full import for any Collection change. In-place collection edits that keep the path stay incremental.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
SEC-1208: test that a concurrent session delete cannot fail a login
Add an API test for POST /api/session. Between the session insert and the login_history insert, another connection deletes the user's sessions, as a password change does. The test logs in 11 times like this. Each login must return 200. Then a normal login must work, and GET /api/login-history/current must show all 12 logins.
The bug is already fixed. Commit 4a7f73029a (#80718, backported to 61 as #80962, first in v0.61.20.1) writes the session row and the login_history row in one transaction. The reporter ran v0.61.1, which does not have it. When that transaction is removed, this test fails as the ticket describes: each login returns 401 with an fk_login_history_session_id violation, and after 10 of them the throttle returns "Too many attempts" for the correct password.
The test does not use mt/with-temp for the user. with-temp binds a transaction connection, and the test client conveys it into the request, so the delete could not see the login's rows. For the same reason, the delete runs on a plain Thread, not a future.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A published table has no page of its own — the rail links it as the
ad-hoc question that reads it, /question#<serialized card> — so there was
no card id to key a selection off and the row never highlighted.
Recover the table from the serialized card in the hash, load it, and
select it. Its collection also feeds the section seed, so landing on or
reloading a table URL stays on Official, matching metrics.
The hash is user-controlled, so its shape is checked rather than
asserted; anything unrecognised just leaves the rail alone.
Opening a metric pushed it into the Unofficial rail's open list. The
Library is a curated reference you navigate to and read, not a piece of
work you open, and it already has its own home in the Official rail.
Skip any card or dashboard whose collection is part of the Library.
Dashboards cannot live there today, but the rule is about the Library
rather than about metrics, so it is applied to both.
The metric routes declare their param as :cardId, but the navbar read
params.slug for every card path. On /metric/:cardId that was undefined,
so the card was never fetched — which meant no collection to derive the
nav section from and nothing to select, leaving a reload on Unofficial
with nothing highlighted.
With the card loaded the rest of the chain already worked: the seed
resolves to Official off the library collection, the selection resolves
to metric-<id>, and the tree auto-expands the ancestors of the selected
node, so the library collection opens with the metric highlighted.
Reloading an official metric dropped the rail to Unofficial: the section
override lives in redux, so a refresh wiped it and the fallback keyed off
the pathname, which says nothing about authority. /metric/... is not
/browse, so it resolved to Unofficial.
Derive a seed from the collection of whatever is open, taking Official
when that collection is marked official or is part of the Library.
Precedence is now explicit choice, then the open entity, then the URL.
getSelectedItems ignored metric paths entirely, so nothing was selected
on a metric page. Handle them alongside questions and models, and carry
the search model on the selection so the Official rail, whose rows are
keyed model-id, can highlight the open entity rather than only its
containing collection.
Switching sections navigated to whatever you last had open in the target
half, so flipping the rail threw away the page you were on. The rail is
a view of the sidebar, not a destination; toggling it should only change
what the sidebar shows.
Drop the navigate call. That makes hrefFor, the per-section last-path
map and the effect feeding it dead, so they go too. The URL still seeds
the initial section for a deep link.
buildCollectionTree already derives each node's icon via
getCollectionIcon, which is where a collection's own icon is honoured.
SidebarCollectionLink then re-derived it from the tree node, whose icon
field is the resolved IconData object rather than the stored name, so a
custom icon never survived to the Official and Library nav sections and
every folder fell back to its type default.
Read the resolved icon off the node instead. The tenant override moves
with it: MainNavbarContainer already passes isTenantUser into the tree
builder, and the one remaining caller that did not is unreachable for
tenant users.
Library folders were limited to the two seeded Data and Metrics
collections. The backend already inherited the library type onto
children, so subfolders worked, but nothing was exposed in Data Studio
and the Library root rejected everything but those two sections.
Top-level folders reuse the existing `library` type rather than adding
a new one: the root is told apart from user folders by is_library_root,
which is keyed off the hard-coded entity_ids and already existed. The
content rules become: the root holds folders only, a user folder holds
tables, metrics and nested folders, Data and Metrics are unchanged.
Collections gain a nullable icon column so a folder can carry a chosen
icon; when it is null the icon is still derived from the type.
canPlaceEntityInCollection now takes the destination collection instead
of a bare type string. The rule depends on root-vs-folder, and those
share a type, so the string alone would have made the Library root a
valid table-publish target.
The frontend move rule disagreed with the backend in both directions,
permitting a Data subfolder onto the root and blocking user folders from
nesting. It now mirrors check-library-update.
* move glossary entity_id migration from v65 to v64 for backport
* reset Liquibase changelog cache in glossary precondition test
* Scope glossary precondition test column check to the current schema
* add remote_sync_task.last_heartbeat_at and a per-task heartbeat thread
* always close a remote sync task row when its worker exits
* close this JVM's running remote sync tasks on graceful shutdown
* reap stale remote sync tasks at boot and when the current-task endpoint observes one
* keep a committed remote sync version as the sync base when its task is cancelled or fails afterwards
* return the initiating user on remote sync task responses
* show interrupted, cancelled, and quiet states in the remote sync progress modal
* report remote sync import progress through every phase with one throttled reporter
* use with-dynamic-fn-redefs for the heartbeat touch-task! redefs in remote_sync_task_test
* log the ids of stale remote sync tasks when the reaper closes them
* fix migration
* materialize data apps inside the import's version transaction
* discover a running or stuck sync from a fresh tab
* drop the unreachable sync interrupted view
* Read c3p0 pool stats from the pool objects instead of JMX
The Prometheus c3p0 collector enumerated every pool MBean and read its
attributes over JMX. A JMX read locks the MBean and then the pool, while
pool construction locks the pool and then the MBean
(swaldman/c3p0#95). The collector guarded against this by holding the
warehouse pool guard, but the application, Quartz and semantic-search
pools are built outside that guard, so a scrape overlapping one of those
constructions deadlocked and then held the guard forever: a fresh pod
passed its health checks but never completed a warehouse query.
`connection-pool-info` now enumerates c3p0's own registry
(`C3P0Registry/getPooledDataSources`, a copy made under the registry
lock) and reads each `PoolBackedDataSource` with the default-user
getters the MBean used. No JMX, no `locking`. A pool closed mid-scrape
throws and is skipped. Metric families, labels and the bug-reporting
endpoint's output shape are unchanged.
Warehouse pools of a non-default connection type get a name suffix
(`-write`, `-admin`, `-transform`) so two pools of one warehouse no
longer share a `database` label and overwrite each other.
The regression test scrapes on one thread while another builds pools
through the app-db path and a third builds a warehouse pool through the
guarded path; on the old collector it deadlocks within a second.
* Disable c3p0 JMX MBean registration
The Prometheus collector no longer reads pool MBeans, but any other
in-process JMX reader (the Datadog agent's JMXFetch, JConsole, a JMX
exporter) can still deadlock a pool construction the same way
(swaldman/c3p0#95). Set the c3p0 management coordinator to the null
one in resources/c3p0.properties so no pool or registry bean is
registered. c3p0 reads the key once through its layered config, with
system properties last, so an operator can restore the beans with
-Dcom.mchange.v2.c3p0.management.ManagementCoordinator=...ActiveManagementCoordinator.
Verified with a standalone harness: with the property, 37534 pool
creations under a concurrent JMX reader over 30 s with no deadlock and
no c3p0 beans; with the -D override, the beans return and the harness
deadlocks in 0.5 s, as it does without the property.
* move the serdes :field normalize schema into a var
* extract the library once on a merging pull or push
* stream the export preflight extraction instead of realizing it
* index each merge side once for the merge and its casualties
* fix test
* share one registered :field normalize schema between serdes and resolve
* test the merging pull and push extract the library once
* Let a v2 MCP tool describe itself from the instance's own data
deftool gains an optional :description-suffix, a fn of no arguments whose
result is appended to the tool description on every tools/list. The static
part stays in the manifest cache and only the suffix is recomputed, so a
suffix that reads the app DB doesn't drag the schema conversion along with it.
tools-hash skips it on purpose. The keepalive polls that hash every 30 seconds
on every open stream, so covering descriptions would buy a change notification
for a suffix at the price of its query per stream per tick. A suffix reaches a
client when it next lists tools instead.
* Add a glossary tool to the MCP server
glossary() returns every term with its definition; glossary(term) returns
one, matched case-insensitively, since a model echoes a term as it read it
rather than as it happens to be stored.
The term names ride the tool's own description rather than waiting behind the
call. A model only looks a word up when something tells it the word is worth
looking up, and the terms that most need a company definition are the ones
that read as ordinary English -- account, active user, churn -- which a model
believes it already understands. Metabot gets this for free by pasting the
whole glossary into every message it sends, because it owns the prompt; here
the description is the only channel that reaches the model unprompted.
Definitions stay behind the call, and the term list is capped at 100. Nothing
bounds a glossary -- no cap on entries, and a definition is TEXT -- and a
description long enough for a client to truncate costs more than its tail is
worth.
A client may cache a description for the life of its session, so a term added
mid-session shows up when that client next connects.
* Keep glossary out of the driver-test trigger set
mcp now uses glossary, and mcp's transitive dependents are effectively the
whole graph because some modules declare :uses :any. That made a glossary
change reach driver, query-processor and transforms, so every glossary edit
would have run the full driver suite. mcp is already excused from this for the
same reason; glossary joins it rather than raising max-allowed-count, since
none of it is driver logic.
Also fixes two whitespace-linter errors: a literal U+2028 landed in the
glossary test where an escape sequence was meant. The separator is now built
with char, which is what the test was trying to say anyway.
* Reject a :description-suffix that only takes one argument
ifn? was the wrong predicate. Keywords, maps, sets and vectors satisfy it but
are callable only with one argument, so they passed registration and then threw
ArityException at the first tools/list -- the deferred failure the guard exists
to prevent. Worse, the accepted registration sat in the shared registry, so the
throw came from listing any tool at all, not from the one that was wrong.
Vars stay allowed: one holding a fn of no arguments is callable, and :handler
is already registered as a var.
* Point the MCP server instructions at the glossary
The initialize instructions are the only channel that reaches the model before
any tool call, so this is where the glossary gets announced.
The line is unconditional on purpose. An instruction to look a term up when you
don't recognize it never fires, because the words that most need a company
definition are the ones that read as ordinary English -- account, active user,
churn -- which a model believes it already understands. So it asserts that
these terms matter and to fetch them before answering, with no qualifier about
which words. A test pins that: it fails if the line goes missing, and it fails
if the line grows a "don't recognize" style qualifier.
Static, so the instructions stay a compile-time constant. Whether an instance
has any terms at all is reported by the tool's own description instead.
* Instruct rather than list, and page the glossary listing
Three fixes from review.
The description no longer names the terms. The old cap was on the number of
terms, but the thing being protected is the length of a description clients
truncate -- Claude Code at 2048 characters. Seventy ordinary-length terms
produced 2444 characters with no pointer to the rest, so the client cut the
tail and the model saw a partial list it had every reason to read as the whole
glossary. That is worse than no list: a model believing the glossary is covered
never calls. So the description now carries the count and an unconditional
instruction to call before answering, which is inherently bounded and asks the
model to notice nothing.
glossary() is paged. It rendered every row, term and definition, into one
block, with definition a TEXT column that nothing bounds at any layer. Now
limit and offset through the usual list envelope, defaulting to 50 and matching
browse_collection -- the closest analogue, a plain unranked listing of rows
carrying prose.
glossary(term) returns every match. term is unique case-sensitively, so "ARR"
and "arr" are two entries with two meanings, and the lookup is deliberately
case-insensitive because a model echoes a term as it read it. Taking the first
match silently picked one and gave no sign the other existed.
* Make the glossary tool description static again
The suffix reported how many terms an instance defines. The only thing that
bought was letting a model skip one call on an instance with no glossary --
a call that is cheap and self-correcting, since it returns "no terms are
defined" and the model moves on. That does not pay for a public extension
point on deftool, its load-time validation, and a query of every glossary row
on every tools/list.
So the instruction moves into the static description and the machinery goes.
registry.clj is byte-identical to its state before this branch; every change
it had carried was in service of the suffix, including the list-tools split
that existed only so tools-hash could avoid computing one.
The instruction itself is unchanged in substance and still unconditional: call
glossary() before answering any question about this instance's data, with no
qualifier about which words, because the words that most need a company
definition read as ordinary English and the question will not say which ones
are defined.
* Simplify description
* Fixes
* Read the glossary through its module API, not its db namespace
The MCP tool required metabase.glossary.db, which meant exporting another
module's raw Toucan namespace just to read from it. metabase.glossary.core is
the module's stated API namespace and says so in its own docstring; it only
lacked a read, which is why db had to be exported at all.
So core gains entries, and db leaves the module's :api. Nothing outside the
module reaches into it now.
* Match the glossary tests to the simpler unknown-term error
The unknown-term error no longer names the terms that do exist, so the two
tests asserting it did were red. They now assert what it says: the term the
caller asked for.
The third was the cleaning guard, which had been riding on that error naming a
stored term. Retargeted onto a successful lookup, where stored text still
reaches prose the model reads as the server speaking. It now covers the stored
definition as well as the term -- definitions had no cleaning coverage before,
and the definition is the longer field and the one a payload would fit in.
The listing is deliberately not covered: list-content JSON-encodes its envelope
and wraps it raw, so it is bounded by JSON escaping rather than by clean, and
asserting otherwise would have been false.
* GDGT-3137: accept emoji up to 10 code points as a comment reaction
The emoji schema counted UTF-16 units, so the England flag (7 code
points, 14 units) got a 400. Postgres and MySQL count varchar(10) in
code points and already fit every picker emoji, so only the schema and
the H2 column change. The MySQL collation set by v63 stays untouched.
* Revert "Disable explorations (#82230)"
This reverts commit c505a9a785.
* add back test improvements
* Restore up-front profile validation in the agent endpoint
The "Disable explorations" PR (#82230) also fixed an unrelated bug: a
request naming a nonexistent profile was not rejected up front. The SSE
stream opened, the turn was persisted, and the conversation-title LLM
call was made before anything failed. Reverting that PR here dropped
the fix along with the explorations changes.
This re-applies only the validation: `profile-registered?` in the
profiles registry, and a 400 "Unknown profile" check in
`streaming-request` right after the profile id is resolved and before
`start-turn!` persists anything or the title job runs. Explorations
stay registered.
---------
Co-authored-by: John Swanson <john.swanson@metabase.com>
test: drop the wall clock from the split modal test
The test gave the metadata request a 100ms fetch-mock delay and then
asserted the loader was still showing. On a loaded CI runner the
render plus the awaits burn through that budget, the loader is gone
and getByTestId throws. Resolve the metadata response explicitly
instead, so the loading window does not depend on timing.
Fixes EMB-2405
Claude-Session: https://claude.ai/code/session_01GuH5ewGP1DzQs5jwNpTgWc
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep permissions tabs visible while data loads
The Data tab returned a bare spinner until databases, groups and
permissions loaded, so the tabs above it vanished on slow networks.
The page layout now always renders and the spinner shows in its
content area, like the Collections tab.
Fixes EMB-2392
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg4bU1ueEbNhR2AYfm96T8
* fix: open the split permissions modal after load
The Data tab now renders its layout while data loads, so the one-time
split permissions modal could open over the spinner. Dismissing it
there turns the user setting off before the table it explains shows.
Only allow the modal once loading finishes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TuekFYoRQBgQoySZe6xB3k
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Return a question from useQuestionFromCard instead of a builder
* Stop exporting the provider factory selector, which has no callers
* Memoise the connected-table link url
* Read the model's table, the list's entity type and the native editor's table from their own requests
* Take the native preview's provider from the store and the metabot table names from Lib
* Let the question carry its own metadata into derived questions and steps
* Take the pristine table URL, the native editor's databases and the last-run card off the Metadata object
* Mock the databases endpoint in the inline SQL prompt spec
* List every MCP v2 tool regardless of token scopes (GHY-4543)
tools/list filtered out tools the token lacked scope for, so a client
holding a narrow grant never saw the write, SQL or delivery tools and
had nothing to attempt or step up for. Scope stays enforced at
tools/call; the disabled-tools and client-extension filters still apply.
With scopes out of the listing, list-tools and tools-hash drop their
token-scopes parameter, and the transport's tools-hash-fn becomes
zero-arity.
* Explain MCP v2 scope denials to the model (GHY-4543)
Clients replace a scope denial with their own text ("token expired",
"Insufficient scope", "Tool execution failed"), so the model tells the
user their login expired. Server instructions now say that failure is a
missing permission, and how the user reconnects to grant it. Every tool
description ends with a static sentence naming its required permission by
its consent-screen label, identical for every token: Claude Code caches the
first description it loads for the whole session.
* Answer MCP v2 scope denials with 403 insufficient_scope (GHY-4543)
A scope denial was an HTTP 200 carrying a JSON-RPC error or an isError
result, so clients never stepped up: Claude Code only saves a step-up
scope, and mcp-remote only starts one, on a real 403 with an
insufficient_scope WWW-Authenticate challenge. The registry gate and the
four in-handler scope checks now mark their refusal, and the transport
answers a single (non-batched) message with 403 plus a challenge whose
scope is the held v2 scopes plus the required one.
* Keep a DCR client's registration scope from narrowing its step-up (GHY-4543)
Claude Code registers with the scope it read from the protected-resource
metadata, then steps up on the same client_id with the wider scope from a
403 insufficient_scope challenge. oidc-provider validates a requested scope
against the client's registered scopes, so that step-up was refused with a
400 invalid_request. Once scopes_supported is narrowed to the baseline, no
Claude Code user could reach write, SQL, or delivery.
A dynamically registered client's ceiling is now its registered scopes plus
the default grant scopes, computed on read. Registering can widen the
ceiling (a client that registers for mb:full keeps it) but never narrows
it. RFC 8707 resource narrowing is unchanged.
* Declare each MCP v2 tool's OAuth scope in securitySchemes (GHY-4543)
ChatGPT only steps up for scopes a tool declares: without per-tool
securitySchemes it re-authorizes for its narrow login scope in a loop,
and with them it re-authorizes once for held plus declared scopes.
The scheme is derived from the tool's static :scope in the cached
manifest, so it is the same for every token.
* Advertise only the baseline MCP v2 scopes to new clients (GHY-4543)
The protected-resource metadata `scopes_supported` and the 401 challenge
`scope` now list only `agent:content:read agent:resource:read`, so users
who never need write, SQL, or delivery never grant them. Claude Code and
the Claude connectors take their first-login scope from the resource
metadata; ChatGPT takes it from the challenge. All of them reach the rest
through the 403 `insufficient_scope` step-up and per-tool
`securitySchemes`.
What the resource accepts is unchanged: RFC 8707 narrowing still keeps all
six v2 scopes, and the authorization-server metadata stays wide because
Codex requests that list on every login and cannot step up.
* List every MCP v2 resource regardless of token scopes (GHY-4543)
resources/list now matches tools/list: it is token-independent, so a client can
see the resources it could step up for, and a client that pre-fetches the MCP
Apps shells before stepping up to agent:query:run does not cache them as
missing. resources/read keeps its scope gate.
* Answer MCP v2 resources/read scope denials with 403 insufficient_scope (GHY-4543)
resources/list now shows every resource, so answering a denied read with
"Resource not found" hides nothing and leaves a client with no step-up
signal. A client that pre-fetches an MCP Apps shell with a baseline token
now gets the same challenge tools/call sends, naming agent:query:run.
Unknown URIs stay -32602; inside a batch the denial is an in-band -32600.
* Lead MCP v2 tool descriptions with their required permission (GHY-4543)
Clients truncate long tool descriptions (Claude Code at 2048 characters),
so the appended "Requires the ... permission" sentence was cut from
document_write and execute_query. The model needs that sentence to name
the missing permission after a scope denial, so it now opens every
description, and the server instructions say it starts the description.
* Serve every MCP v2 resource on resources/read regardless of token scopes (GHY-4543)
Claude Desktop reads an MCP Apps shell alongside the tool call. When that
read was refused with 403 insufficient_scope, Desktop never stepped up after
the tool call's own 403; with the shell served, it stepped up automatically
and retried. The shell carries no data and, for a token without the shell's
scope, no UI credential: read-resource now withholds the credential instead
of refusing the read. Chart data stays gated by the tool call,
refresh_ui_credential, and the UI-surface route costs.
* Restore the scope check on MCP v2 data resource reads (GHY-4543)
Only the MCP Apps UI shells need to be served to any token: a refused shell
read stopped Claude Desktop stepping up after the tool call's 403. The fields
catalog carries data and has no such constraint, so reading it without
agent:resource:read again gets the 403 insufficient_scope challenge (in-band
-32600 inside a batch). UI shells stay unscoped, minting a credential only for
a token holding their scope.
* Fit MCP v2 tool descriptions under Claude Code's 2048-character limit (GHY-4543)
Claude Code truncates tool descriptions at 2048 characters, so document_write and
execute_query lost their closing guidance, including execute_query's pointer away
from execute_sql for filtered-dashboard cards. Condense both, and get_content for
margin, deferring detail to learn() topics and argument schemas. A test now holds
every listed description to the limit.
Also correct docstrings and comments left stale by the resources/read and
tools/list scope changes and by credential delivery through refresh_ui_credential.
* Spell out the scope union in the MCP v2 challenge docstring (GHY-4543)
The whitespace linter rejects the confusable Unicode character U+222A.
* Add agent:query:run to the MCP v2 baseline scopes (GHY-4543)
Claude Desktop retries a tool after a step-up over a session that does not
declare MCP Apps support, so a stepped-up visualize_query is refused and the
first chart after a step-up never embeds. Querying and charting therefore
must not require a step-up. The protected-resource metadata, the 401
challenge and default-ask-scopes now advertise agent:content:read
agent:query:run agent:resource:read; writes, raw SQL and delivery still step
up.
* Point MCP v2 chart requests at visualize_query in the server instructions (GHY-4543)
The routing line named only execute_query, and clients like Codex load tool descriptions on demand, so chart requests could miss visualize_query.
* Mint only the scopes chosen on the OAuth consent decision (GHY-4555)
The signed scope stays the offered set. The user's choice arrives in a
separate, repeatable granted_scope field and may only narrow it: a choice
outside the offer is rejected as params_tampered, and an approval that
leaves nothing granted is a 400. Offered MCP baseline scopes are always
granted. The token response already echoes the code's scope, so it now
names exactly what was granted.
* Add per-scope checkboxes to the OAuth consent page (GHY-4555)
Each offered scope is a granted_scope checkbox inside the consent form.
The six MCP v2 scopes are listed least to most harmful, with any other
scope after them in request order. The MCP baseline scopes are ticked,
disabled, and marked as always granted; every other scope, mb:full
included, starts unticked. The full-access warning now sits in the
mb:full row.
* Gate native query handles in transform_write on agent:sql:run and the kill switch (GHY-4543)
A query handle is not proof the SQL gates were spent: /api/embed-mcp/drills
stores a native query under agent:query:run alone. transform_write now runs
the same scope and mcp-execute-sql-enabled checks on a handle that resolves
to native SQL as it does on an inline native definition, matching
question_write.
* Tighten resource registration, step-up scopes, and the DCR ceiling (GHY-4543)
- register-resource! takes a closed map and strips :ui?, so a data resource
can never be served to a token without its scope.
- step-up-scopes matches held scopes instead of looking them up, so a token
holding a wildcard such as agent:content:* keeps the surface scopes it
covers when a client replaces its grant with the challenged scope. It now
takes the single required scope both callers pass.
- The default grant ceiling is applied only while dynamic registration is
enabled, so turning registration (or MCP) off stops widening existing
dynamic clients. The merge is now a pure function with unit tests, and an
HTTP test pins that static clients are never widened. The legacy-client
migration test runs with registration disabled, where the stored snapshot
alone decides, so it exercises the migration again.
* Debounce the OAuth consent decision and require a ticked scope (GHY-4568)
The consent page gets an inline script, nonce'd and opted into a
script-src nonce so a production CSP allows it. On submit it shows a
spinner on the clicked button and disables both, carrying the decision
in a hidden input since a disabled button is not submitted, so a second
click can no longer race the first. The buttons come back when the page
is restored from the bfcache. It also keeps Authorize disabled while no
scope checkbox is ticked (GHY-4555).
* Correct MCP v2 tool descriptions, the documents skill, and server instructions (GHY-4543)
- document_write lists the valid entity-link models again.
- The documents skill said edits store new_str literally; the tool parses it
as Markdown, as edit-replacement-parses-markdown-test pins.
- execute_query no longer claims to save cards (its handles save as cards),
and names the previous-stage field ref shape instead of an ambiguous
column-name string.
- The server instructions attribute the rewritten permission failure to the
client and cover resource reads, which get the same 403.
* Tell the MCP v2 model which scopes the connection holds (GHY-4555)
The initialize instructions are now built per request from the calling token's scopes. A scoped token is told which v2 surface permissions it has and lacks, by consent-screen label; an unrestricted caller gets no list. The instructions also stop claiming the consent screen has no per-permission choices: a permission the connection lacks starts unticked, so the model warns the user to tick it.
* Note the unticked consent checkbox in the MCP v2 insufficient_scope description (GHY-4555)
Tool and data-resource 403 challenges now end their error_description with a note that the permission starts unticked on the consent screen and the user must tick it. The README describes the new consent screen and the per-connection instructions.
* Rename the agent:resource:read consent label to "Read MCP resources" (GHY-4555)
* Tighten the MCP v2 scope instructions (GHY-4555)
The general paragraph states the scope-failure guidance once; the per-connection paragraph carries only the held and missing permissions and how far to trust that list.
* Pin the MCP v2 scope-denial and UI-credential contracts in tests (GHY-4543)
Closes gaps the review found, where removing a check would stay green:
- question_write and subscription_write in-handler denials carry
:insufficient-scope at the registry, like alert_write's.
- A denied tools/call sent as a notification is a bare 202, no challenge.
- Wildcard and unrestricted tokens get the shell credential; the fields
catalog mints none, over the registry and over HTTP.
- tool-and-resource-scopes-agree-test asserts the credential is realized,
since any token now reads a shell.
- advertised-scopes-are-distinct-test covers the advertised baseline, which
is a vector and can repeat, and its docstring says so.
* Clean up MCP v2 scope-denial helpers, header quoting, and docs (GHY-4543)
- Share one scope-denial message and detail builder between tool calls and
resource reads, one step-up challenge helper, and a common
throw-insufficient-scope! for the four in-handler scope checks.
- quoted-string replaces " and \ instead of backslash-escaping them, since
RFC 6750 excludes both from scope and error_description values; the 401
challenge now quotes through it too.
- Trim docstrings to their contract and move the reasons into comments.
- The unrestricted UI credential comes from any unrestricted session (cookie,
API key, or an mb:full token), not only cookie sessions.
- Note that the DCR ceiling applies only while registration is enabled, and
fix stale comments, test names and strings, long lines, order-sensitive
scope assertions, and hard-coded consent labels in tests.
* List scope IDs only in the MCP v2 instructions, have the model make the call once the user agrees, and cap instructions at 2048 characters (GHY-4555)
* Pre-tick consent scopes the same app already holds (GHY-4555)
A step-up asks for the held scopes plus the new one, and every non-baseline box started unticked, so ticking only the new scope silently dropped a held one. A scope that a live access or refresh token of the same app holds now starts ticked but stays enabled. The same app is the same client_id, the exact same https redirect, or a loopback redirect with the same host, path and client name on any port, matched against the redirect this request delivers the code to.
* Narrow the same app's tokens when a held scope is unticked (GHY-4555)
Unticking a pre-ticked scope now removes exactly that scope string from the same app's other live access and refresh tokens, so the choice holds across windows. Only scopes offered on the screen and held by the app are removed; nothing is ever added, and a token is revoked only when no scope is left. This runs at the approve decision, after the code is issued, because the authorization code row has no field to carry the declined scopes to the token exchange without a migration.
* Tell the MCP v2 model that reconnecting before a refused call won't offer the permission (GHY-4555)
* Always widen dynamic OAuth clients to the MCP scopes (GHY-4543)
Claude Code registers with the baseline scopes and steps up on the same
client_id. With dynamic registration turned off, that step-up /authorize
was answered with a raw 400. A dynamic client now always gains the six v2
MCP scopes on read, each still behind consent; the remaining agent-API
ceiling is added only while registration is enabled. Registration itself
stays blocked.
* Close the MCP v2 scope test gaps (GHY-4543)
Cover the transform_write update path's native gates, the HTTP 403
insufficient_scope challenge question_write and transform_write send from
inside their handlers, saving a drill handle minted through
/api/embed-mcp/drills, and the closed resource registration schema. Give
the locale test a positive control, merge the two tools/list
token-independence tests into one that compares the whole payload, and
drop the permission-sentence truncation test the fit and starts-with
tests already cover.
* Tidy MCP v2 scope wording and naming (GHY-4543)
Rename registry/insufficient-scope to insufficient-scope-detail so it
does not read like the transport fn of the same name. Drop the server
instructions clause telling the model to read a resource's scope off an
error clients hide. Document agent:resource:read and the handler-internal
scope checks in the module README, correct the stale comments about UI
resources gating on agent:query:run, and wrap the lines this branch left
over 120 columns.
* Let the test fallback template win over a local frontend build (GHY-4543)
The MCP Apps shell renders from the built `embed-mcp.html` when one exists,
so a worktree that has run a frontend build shadowed the inline fallback the
credential tests install. The production template has embedded no
`uiCredential` since #81041, so those tests failed locally and passed on CI,
where backend-only runs produce no build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QT1v8on6aPT2UeHCojGj8
* Tell the MCP v2 model a permission can be removed mid-session (GHY-4555)
* Drop cross-connection scope narrowing from the OAuth consent decision (GHY-4555)
Unticking a scope the same app already held used to strip it from that app's
other live access and refresh tokens. Every verified defect in this PR lived in
that path:
- the authorization code and the approved event are persisted before the
narrowing runs, and both sit inside one `try`, so a narrowing failure answers
400 while a redeemable code exists and the old tokens keep the scope;
- dynamic client registration is open, so an attacker can register a client
carrying another app's https redirect -- or a loopback redirect with a copied
`client_name`, since the port is ignored -- and strip or revoke the real app's
tokens after phishing a single Authorize click;
- a pending authorization code is not narrowed, so redeeming one mints a
just-removed scope straight back.
No comparable provider does this: GitHub, Google, Microsoft and Slack all leave
an existing grant alone when a new authorization asks for less. Taking a granted
permission away deserves its own flow, with its own confirmation and audit
trail; a follow-up ticket covers it.
What stays is the pre-tick: the consent page still ticks a scope the same app
already holds, so a step-up does not silently drop it, and the same-app matching
that finds those tokens is unchanged. A test now pins that an untick leaves the
app's other live tokens -- access and refresh -- exactly as they were.
Also drops the two db.clj scope-update helpers nothing else used, and corrects
two claims the removal falsifies: the MCP `initialize` instructions no longer
tell the model that another window can untick a permission mid-session (a
re-authorization without it, or a revocation, is how it goes away now), and the
MCP README says the connection's scopes are listed by scope ID rather than by
consent-screen label.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Store a repeated OAuth scope once in the granted token (GHY-4555)
`scope=agent:content:write agent:content:write` was split without
deduplication, and the decision filters the grant from that list, so the
duplicate reached the token row and the token response's `scope`. The consent
page already deduplicated for display, so nothing on screen hinted at it.
Deduplicate in `scope-tokens` instead, which is the one place both the page and
the decision read the requested scopes from.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Pre-tick the OAuth consent page from the app's newest live grant (GHY-4555)
The held set was the union of every live token the app had for the user. A
refresh token outlives several authorizations, so the union let an old, wider
grant keep setting the defaults: day 1 grants content:write and sql:run with a
30-day refresh token, day 5 the user re-authorizes and leaves sql:run unticked,
and on day 10 the union pre-ticks sql:run again from the day-1 token -- silently
undoing the day-5 decision every time they re-authorize.
Take the newest live grant instead, so the user's latest choice is the default.
A grant's access and refresh rows are written by one token request and share a
`created_at`, so the newest timestamp names a grant rather than a row: every row
carrying it speaks for that grant, which is what keeps the newest grant readable
once its access token has expired and only its refresh token is left. Erring
narrow is safe now that an untick only shapes the token being minted.
Also records why the loopback key ignores the port, and what that costs, next to
the branch that decides it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Drop consent-page pre-ticking of already-held scopes (GHY-4555)
Every non-baseline scope now renders unticked, whatever the user already
holds. Pre-ticking needed to identify the connection being stepped up,
and the consent page cannot:
- the step-up challenge expands a wildcard grant like `agent:content:*`
into concrete scopes, but the held check matched exact strings, so
`content:write` started unticked and a step-up dropped it;
- refresh rotation writes new rows with a current `created_at`, so an
older broad grant refreshed after a newer narrow one looked newest and
re-ticked a scope the user had dropped;
- the newest app-wide grant is not necessarily the connection stepping
up: window A holds content:write, window B authorizes later without
it, then A steps up and gets B's defaults;
- prechecking across registrations is a consent boundary. Registration
is unauthenticated, so a local app copying the client name and
loopback callback path, or a client registering another app's https
redirect, got those scopes from one generic Authorize click instead of
the user selecting them.
Doing it properly needs grant identity carried into the authorize flow,
the redirect recorded on tokens, and wildcard-aware matching. GHY-4627
tracks that.
Removes `metabase.oauth-server.held-scopes` and the `db` helpers that
existed only for it. A test pins the removal: with live access and
refresh tokens holding `agent:content:write`, the consent page still
offers it unticked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Correct two MCP scope claims in the README (GHY-4555)
Per-tool `securitySchemes` is draft SEP-1488 (modelcontextprotocol issue
1488), supported by ChatGPT. It is not part of MCP 2025-03-26, the
version this server reports, nor of the final 2026-07-28 tools spec, so
other clients learn a missing scope from the 403 instead. The comment
above `default-ask-scopes` implied it was generally available too.
Automatic scope step-up is likewise defined in MCP 2026-07-28, so
clients are not required to implement it against this server: a scope
left unticked can trigger another step-up in clients that support it,
while others need manual reauthorization.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Drop the per-connection permission list from the MCP v2 instructions (GHY-4555)
The list caused the failure the "make the call anyway" clause was added to
fix: with it in context the model sometimes refused a write without calling
the tool, so no 403 came back, the client saved no step-up scope, and the
user's re-authorization offered only the baseline — the permission never
appeared as a checkbox. Without the list and without that clause, the model
calls the tool, reads the 403, names the permission and gives the reconnect
steps.
So the `initialize` instructions are one static string again: general
scope-failure guidance, plus one sentence that every optional permission on
the consent screen starts unticked, so the user re-ticks the ones they want
to keep. `transport.clj` goes back to a plain `:instructions` option.
* Tell the MCP v2 model the tool list is not a permission grant (GHY-4555)
Every tool is listed whatever the token holds, but a scope-filtered list is
the conventional design and the protocol has no field to signal ours. Asked
what a baseline connection could do, the model read the roster as a grant and
answered that it held every agent scope, reasoning that the server filters
tools by granted scope -- true of Metabase until the filter was removed.
Only a failed call reveals a missing permission, so say that.
* Fix counterfactual docstrings and pin the offered scope non-blank (GHY-4555)
Two docstrings stated the behavior the dedup prevents as though it were the
behavior it has, so a reader chasing the invariant learned the wrong one.
Both now say "would be stored twice".
scope-to-grant gains a postcondition. The decision endpoint answers a nil
grant with 403 params_tampered, which is only the right status while the sole
way to reach it is a choice naming an unoffered scope. That holds because
scope-to-grant throws rather than returning a blank offer; the postcondition
turns a future relaxation into a failing assertion instead of an honest
approval reported as tampering.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
GitHub starts no workflow run for a push made with `GITHUB_TOKEN`, so
enabling auto-merge with it lands backports on release branches, and
translation updates on master, with no CI at all. Approving still uses
the other token, since a token cannot approve its own pull request.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Reject unregistered scopes in OAuth dynamic client registration
POST /oauth/register is unauthenticated and stored any scope string the
client sent. A client's registered scopes are the ceiling /oauth/authorize
checks requests against, so a self-nominated `*` or `agent:*` could later
be requested and granted, and scope-matches? treats both as wildcards.
Registration now answers 400 invalid_client_metadata, naming the offending
scopes, when any requested scope is not a registered scope. An omitted
scope still gets the default ceiling, and `mb:full` is still accepted.
* Reject unregistered scopes at OAuth authorize
Clients that registered a wildcard such as `*` before registration
validated scopes can still request it, since /oauth/authorize only checks
a request against the client's own registration. /authorize now answers
400 invalid_scope when any requested scope is not a registered scope. The
check runs before resource narrowing, which would otherwise drop the
scope when `resource` is sent and keep it when it is not.
Both endpoints now use one error_description that points the client at
the authorization server metadata listing scopes_supported, instead of
echoing the scopes it sent.
Clients registered on v0.60-v0.61 still hold agent:database:read,
agent:table:read, and agent:metric:read, which are no longer registered;
requesting them now fails. The authorize test fixtures move from the
unregistered "profile" to agent:content:read.
* Reject OAuth authorize requests without a scope
A request to /oauth/authorize with no scope rendered a consent screen
listing no permissions and minted a token with no scopes. It now answers
400 invalid_scope whether scope is absent, empty, or whitespace. A blank
scope is dropped before the provider validates the request, so an unknown
client or mismatched redirect URI is still reported first.
The error_description names no request input and points at the
authorization server metadata listing scopes_supported.
The OAuth authorizations E2E spec drove consent without a scope; it now
requests agent:content:read.
* Reject an empty scope in OAuth dynamic client registration
A registration that sent scope as null, an empty string, or whitespace
was stored with no scopes, and such a client can never authorize now
that /oauth/authorize requires a scope. /oauth/register answers 400
invalid_client_metadata instead, with an error_description pointing at
scopes_supported.
Omitting scope still registers the client with the default ceiling, which
is what MCP clients that register without a scope rely on.
* Fail closed on OAuth bearer requests without token scopes
Nil :token-scopes means scope-unaware auth, and sessions, API keys,
the scope middleware, the agent API, and the MCP transport all let it
through as unrestricted. For an OAuth access token, only an empty set
surviving every hop kept a scope-less token from becoming unrestricted.
The session middleware no longer authenticates an access token with no
scopes, and marks requests it does authenticate with an OAuth token as
:authenticated-via-oauth?. Behind it, enforce-scope and
ensure-scopes-checked answer 403, the agent API stops defaulting nil
scopes to unrestricted, and the MCP transport answers its invalid_token
401 for an OAuth-authenticated request with no scopes.
The marker is set by the session middleware rather than inferred from an
Authorization: Bearer header, which the agent API JWT path also uses and
which a session request may carry alongside its session. Sessions, API
keys, JWT, and the MCP Apps UI credential never set it.
* Redirect OAuth authorize errors to the client per RFC 6749
GET /oauth/authorize answered every validation failure with a 400 JSON
body, which strands the user on a raw error in their browser tab and
leaves the client waiting for a callback that never arrives.
The endpoint now looks up the client and checks the redirect_uri is an
exact match for one it registered before validating anything else. If
either is missing or invalid, it keeps the non-redirect 400, as RFC 6749
section 4.1.2.1 requires. Every other failure redirects to that
registered redirect_uri with error, error_description, state, and the
RFC 9207 iss parameter, built by the library's denial redirect builder.
Error codes: invalid_scope for unregistered, missing, fully narrowed,
and unregistered-for-the-client scopes (the last was mislabeled
invalid_request); unsupported_response_type for a response_type the
client did not register; invalid_target for an invalid resource
indicator; invalid_request for PKCE problems and malformed parameters.
* Send RFC 6750 bearer challenges on scope-denial 403s and agent API 401s
RFC 6750 section 3 requires a resource server to answer a bearer token
that does not grant access with a WWW-Authenticate challenge. The scope
middleware's 403s, including the fail-closed ones for OAuth requests
without scopes, sent only a JSON body, so an OAuth client could not tell
it should re-authorize for more scope.
enforce-scope and ensure-scopes-checked now add
Bearer error="insufficient_scope", scope="...", error_description="..."
with scope naming the endpoint's required scope, and omitted in
ensure-scopes-checked, where the endpoint declares none. The format and
value sanitizing match the MCP transport's insufficient_scope challenge.
The JSON bodies are unchanged.
The agent API's 401 for a bearer token that does not authenticate now
carries Bearer error="invalid_token".
* Answer malformed OAuth resource indicators with client errors, not 500s
A resource value that is not syntactically a URI, such as
"http://bad uri", made oidc-provider's resource check throw a
URISyntaxException rather than an ex-info. Nothing caught it, so
/oauth/authorize, /oauth/authorize/decision, and /oauth/token all
answered a client input error with a 500.
Each endpoint now checks that every resource indicator parses before
handing the request to the library:
- /oauth/authorize: once the client and redirect_uri are validated, an
RFC 8707 invalid_target error redirect. Otherwise the existing
no-redirect 400.
- /oauth/authorize/decision: the existing 400 invalid_request, with no
redirect, since its parameters are untrusted until the signature
verifies.
- /oauth/token: a 400 invalid_target.
Only URISyntaxException is caught, and only while parsing the resource
values.
* Send a plain Bearer challenge on agent API 401s without a bearer token
RFC 7235 requires a WWW-Authenticate challenge on every 401. The agent
API's missing_authorization 401 (no credentials) and
invalid_authorization_format 401 (a non-Bearer Authorization scheme)
sent only a JSON body.
Both now carry WWW-Authenticate: Bearer. There is no error attribute,
because RFC 6750 section 3.1 says a challenge to a request with no
bearer token should not include an error code. Status codes and bodies
are unchanged.
* Keep OAuth authorize errors non-redirecting
Delivering /oauth/authorize errors as a redirect to the client's
registered redirect_uri made Metabase a zero-click open redirector.
Dynamic client registration is unauthenticated, so anyone can register
a client whose redirect_uri points at their own site, and a single
/oauth/authorize link on the trusted Metabase host would then send any
logged-in user there with no consent step. RFC 9700 section 4.11 says to
answer with an error rather than redirect when the redirect URI is not
trusted.
Every error from GET /oauth/authorize is a 400 JSON body rendered in the
user's browser again, with no Location header. The login redirect for
unauthenticated users is unchanged.
The error codes stay accurate: a scope outside the client's registered
set is invalid_scope, an unregistered response_type is
unsupported_response_type, and an invalid resource indicator, including
one that is not syntactically a URI, is invalid_target. Malformed
resource indicators still cannot reach the 500 they used to.
* Drop unregistered scopes at /oauth/authorize instead of refusing
Registration and authorization see the same problem from different
sides. An error from /oauth/register reaches the client program at setup
time, where it can act on it, so that endpoint keeps rejecting
unregistered scopes outright. An error from /oauth/authorize lands in a
user's browser tab as JSON while the client waits for a callback, and it
also breaks step-up for a client that still holds a scope shipped in
v0.60-v0.61 and since removed from the registry.
/oauth/authorize now filters the requested scopes down to the registered
ones and carries on with what survives, which RFC 6749 section 3.3
allows. Filtering runs before resource narrowing, so a request behaves
the same with and without a resource indicator, and the consent page and
the minted token carry exactly the surviving scopes. Least privilege is
unchanged: an unregistered scope, including a wildcard a client
registered before registration validated scopes, still can never be
granted.
A request in which nothing survives the filter is refused with
invalid_scope and its own description, distinct from the one for a
request narrowed away by its resource indicator. A missing, empty, or
whitespace-only scope is unchanged.
* Enforce the scope rules where the authorization code is issued
The HMAC over the consent form is keyed by the CSRF token, which the
consent page prints for the user, so the user can recompute it over
whatever scope they like. It proves no third party tampered with the
form; it does not prove the scope was ever validated.
/oauth/authorize/decision is what issues the code, so it now applies the
scope rules itself instead of trusting the signature.
Both endpoints call one scope-to-grant function: refuse a missing or
blank scope, drop the scopes that are not registered, refuse when none
survive, and grant exactly what is left. A denial grants nothing and
skips it. /oauth/authorize behaves as before; a refusal at the decision
endpoint keeps its existing 400 JSON shape and never redirects.
A correctly signed form asking for a scope the consent screen never
showed was previously honored: a re-signed wildcard minted a token
carrying it, and a re-signed blank scope minted one with no scopes at
all.
* Tidy the OAuth scope and challenge code
Follows the style review. No behavior change except one described below.
- narrow-scope-to-resource's docstring described a contract it no longer
has: that nil means both "nothing requested" and "nothing survived",
that the caller may drop the parameter, and that narrowing can never
turn a valid authorization into a rejected one. It returns nil when no
requested scope survives, and the caller refuses.
- One check-resource-parseable! replaces the same pre-check written three
times with three different ex-data shapes, and the note about why it
exists sits on unparseable-resource! once instead of at each call site.
- invalid_target now always carries the description saying what a valid
resource indicator looks like. It used to depend on where the error
came from: the pre-check said so, while a relative indicator or one
with a fragment fell back to the generic description. This is the one
behavior change, and the authorize test that pinned the generic
description is updated.
- The three inline invalid_client_metadata 400s in /register go through
one helper.
- The error_description strings and their helpers move out of the
module's core namespace, where they were public with a single caller,
into the api namespace as private defs grouped together.
- The MCP transport's two invalid_token 401s were identical; they share
one response now.
- The agent API's error-response takes the WWW-Authenticate challenge as
an argument, which is what all three of its callers were adding
afterwards.
- Dropped an inaccurate comment about parameter order matching the MCP
transport's challenge, some rationale that belongs in line comments
rather than docstrings, and a double negative.
* Tighten the OAuth scope and challenge tests
Follows the style review.
- quoted-string is now exercised directly with a quote, a backslash and
non-ASCII input. The charset assertion it replaces only ever saw
constants, so it could not fail.
- The agent API's invalid_token 401 test moves to the agent API tests,
where the behavior lives, and its no-credentials case goes: that is
already covered there.
- Deleted bearer-token-without-scopes-gets-no-tool-access-test. Its 401
comes from the transport's invalid-bearer branch, because the session
middleware declines a scope-less token before the transport sees it, so
it passed with or without the fail-closed branch and duplicated
bearer-bridge-refuses-token-without-scopes-test. The branch itself is
covered by oauth-request-without-token-scopes-is-refused-by-the-
transport-test, which invokes the handler directly.
- The DCR unregistered-scope test compares the whole error_description
instead of a substring plus assertions that cannot fail.
- Scope-denial tests assert the exact error code each case produces
rather than accepting either.
- A test description stated its own history; it states the behavior now.
- Wrapped lines over 120 characters.
* Declare :authenticated-via-oauth? in the request schemas
Master closed ::current-user-info and added a closed ::request schema
enumerating every key the middleware stack may attach. This branch marks
a request the OAuth bearer bridge authenticated with
:authenticated-via-oauth?, which both schemas then rejected as an extra
key: the bridge threw on its return value, so no OAuth bearer request
could authenticate.
* Describe only our own resource indicators as malformed
The error_description helper answered every invalid_target that carried no
description of its own with the one describing a malformed resource indicator.
oidc-provider raises that code without a description for a different condition:
a refresh whose resource is outside the original grant. A client sending a
well-formed absolute URI was told its URI was malformed.
The endpoint's own check now carries its description in the exception, and the
helper falls back to the endpoint's generic description for anything else. That
check also validates what it claims to describe, so a relative indicator or one
with a fragment is answered by it rather than by the library.
* Answer 401 when no JWT provider is registered
The agent API's stateless-JWT fallback dispatches auth-identity/authenticate
on :provider/jwt. That provider ships only in EE, so on OSS nothing derives
the keyword into the auth-identity hierarchy and the dispatch throws
UnsupportedOperationException. Every bearer token the OAuth bridge declines
falls through to it, so the agent API answered 500 with a stack trace instead
of the RFC 6750 invalid_token challenge it owes the request.
Treat an unregistered provider the way a disabled one is treated: the same
jwt_not_configured result, which enforce-authentication already renders as a
401 carrying the invalid_token challenge.
Also stop bearer-bridge-refuses-token-without-scopes-test from holding
with-temp's rollback-only transaction open across its four HTTP round-trips.
The request runs on a conveyed connectable, so anything in its path that
commits implicitly invalidates the savepoint and the scope exit throws
instead of rolling back.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Starting a conversation with Metabot now puts it in the rail's Open list, next
to the questions and dashboards you have open, so the sidebar panel's thread is
reachable again after you navigate away from it.
- `getStartedConversations` lists the conversations with at least one message,
in creation order. Empty ones are skipped — an agent always holds a
conversation, so listing those would show a row for Metabot merely existing —
and the SQL-suggestion and explorations agents are excluded, since they answer
in place and have no conversation page to link to.
- Rows link to /metabot/conversation/:id. The title is generated after the first
answer, so a row starts as "New conversation" and renames itself in place when
the title lands.
- The selector holds its result identity while ids and titles are unchanged, so
a streaming answer does not re-fire the effect on every token.
Data gets the same treatment as collections: one rail row, no section heading,
and a drawer holding the thing itself rather than a link to a page about it.
- The row reads Raw data and opens a drawer with the warehouse as a tree:
databases, their schemas, and the tables in them. Every row links to the
browse page it stands for, so the tree is a faster route to the same
destinations, and any node can be expanded in place instead.
- Children are fetched only when a node is expanded, so opening the drawer costs
one database list and nothing else.
- Tables with virtual ids (cards in the Saved Questions database) are left out:
they have no /table/:slug route to link to.
- The Add data action moves from the old section header into the drawer header.
`BrowseNavSection` and its spec are gone, along with the
`expand-browse-in-nav` setting it read.
- The Data section (Databases) moves inside the Unofficial branch. Official is
curated content; browsing the warehouse belongs to the creation half.
- Getting Started starts collapsed, so the rail opens on the user's own work
rather than on onboarding.
- Trash moves to the bottom of the collections drawer, where the things it holds
were deleted from, instead of being a permanent rail row.
* BOT-1956: Fix permission-check gaps in transform/query export
Card/measure/segment export lookups routed through the loud, audit-logging
read-check even for routine filtering, one query-export call site defaulted
to the unchecked store, and a withheld query left a dangling header instead
of saying so.
* BOT-1956: Audit the permission check on client-supplied query ids
Ids arriving inline in the viewing context are a real access attempt, so the database gate
and the card ids inside the query both keep the audited read-check while stored-query export
stays quiet. export-query-for-llm now takes an explicit content store, so every call site
shows which one it picked.
* Don't audit permission refusals the caller discards (#79969)
* BOT-1956: Resolve -1337 to the real database and gate transform sources
The virtual id passed the database gate as a 404 while the export then
resolved it to the source card's real database, so the gate now resolves
first and fails closed on ids it cannot. Transform sources get the same
gate, queries and charts seeded from the viewing context keep the audit
trail when a read_resource denial withholds them, and the withheld
message no longer blames the database when a card inside the query was
the refusal.
* BOT-1956: Drop the em dashes from two docstrings
* BOT-1997: Don't feed Metabot queries the user has no permission to run
* BOT-1997: Shorten the comments on the Metabot permission checks
* BOT-1997: Close the two remaining fail-open paths in the query export
Exports now defer to the query processor's can-run-query?, the same
check the save path uses, instead of a database-wide can-query?
shortcut plus SQL analysis. For native stages that means the
database-level query-builder-and-native rule with no parsing: the
analyzer was best-effort and could return an incomplete table set with
no completeness signal, so a clean analysis never proved the query was
authorized, and unsupported drivers withheld queries from fully
authorized users. Deferring to the QP contract also keeps queries
sourced from saved questions working; those authorize through the
card's collection, not through database query permission. The
name-resolution checks (queryable tables, sandbox-visible fields,
readable cards) stay on top of the run check.
The same check now also covers conversation state. read_resource used
to export a stored query after only a database read-check, so a user
who could read the database's metadata but not run the query could
recover resolved table and field names through metabase://query/{id}
and metabase://chart/{id}. Both fetchers now share the viewing-context
formatter's exportable-query? check.
* BOT-1956: correct three stale comments
The withheld-message docstring claimed a deleted database is withheld too, when it
deliberately renders. The content-store test docstring counted three pinned properties
while listing four, and the repair comment called every pass best-effort when only the
mini-resolve lookups are.
* BOT-1997: authorize conversation queries by run permission alone
The query and chart resources read-checked the raw :database before the run-permission
check, which broke the two cases the canonical check exists for: a collection-only user
running a saved-question query got a 403 from a database they never touch, and a legacy
-1337 database id 404ed before normalization could resolve it through the source card.
The read-check is gone and the viewing-context predicate now lets normalization resolve
-1337 instead of gating on the raw id.
The source-card tests also stop granting manage-table-metadata, which had made the
database readable and hidden exactly these cases; they now run with no database
permission at all, over both the real and the -1337 id, alongside a new refusal test
for a card in an unreadable collection.
* BOT-1997: Hide transform source queries the user can't run
Reading a transform is authorized per database, but rendering its source resolves
table and field ids to names, so the details tool and the transform resource now
drop the query unless the user could run it. The viewing-context path also stops
normalizing twice and logs a query it can't permission-check at debug, instead of
letting the calculator log an error on every prompt.
* BOT-1956: make the query export gates table-granular
* BOT-1956: fail closed when a native query can't be analyzed
* BOT-1956: Fold the two query database gates into one with an audited? flag
* BOT-1997: Calculate perms inside the try, assert the 403 text in tests
* BOT-1956: Check exports with can-run-query? instead of parsing native SQL
* Build the saved-question test card with Lib instead of the deprecated mbql-query
* Use metabot.db/database-exists? instead of a direct Toucan call
* BOT-1956: Name can-run-query?'s flag for the errors it rethrows
* BOT-1956: Suppress repair's refusal auditing at its call site
* BOT-1956: Keep :client-ids out of the conversation state
The ids describe the viewing context of the request being served rather than the conversation, and final-state-part was handing them back to the client as if they were state.
* BOT-1956: Gate a query export with one Database read and one normalization
* BOT-1956: Audit a chart's refusal against the query it was built from
* BOT-1956: Make run permission the whole gate on a query export
Reading the database was checked on top of it, which withheld a saved question from a user who can read the card and run it but has no grant on the database behind it. Dropping that check also leaves one Database lookup where there were two, so a deletion landing between them can no longer escape as a 404.
* BOT-1997: Render a query we could not permission-check rather than hiding it
A query that will not normalize, or whose permissions will not calculate, was reading as a refusal and disappearing from the transform output and the conversation resources, admins included. It now comes back without a metadata provider and renders through the paths that resolve no ids to names, which is what master did with its EDN fallback.
* BOT-1997: Cover the cases the export gate decides on a transform source
* BOT-1997: cljfmt
* BOT-1997: Make the unresolved fallback actually withhold names
A query that will not parse never reached the fallback, because normalization degrades to an empty map unless it is asked to be strict, so those queries still read as refusals and disappeared. Transform rendering then threw away the checked query and its provider and resolved the source a second time through an unrestricted provider, which put resolved names back in front of the model for a query whose table or sandbox lookup had thrown. Native SQL needed its own answer either way, since its table and column names are already spelled out in the text and having no provider hides nothing, so it stays out until the check has actually run. Rendering now happens where the check's result is still in hand, and an export failure falls back to the query the caller passed rather than the normalized form.
* BOT-1956: Keep a client query's provenance for as long as the query lives
Carried over from the review on #79877, which merged before this was picked up. The ids of queries and charts the client sent were request-local, so a tool could persist one of those queries and a later turn read it back after it had left the viewing context, by which point it looked tool-written and a refusal to show it went unaudited. They live in conversation state again, declared in the state schema this time, and each turn adds to what earlier turns recorded.
* BOT-1997: Tell a check that was never needed from one that could not be made
Both came back as a query with no metadata provider, which conflated two different situations. A transform whose source database has been deleted has nothing behind it to resolve, and an admin is supposed to still read that source while repairing it, but it was being withheld like a query whose permissions had thrown. Going the other way, native SQL was only held back when the source had a single stage, because the check for it was the helper that extracts a complete query's SQL and deliberately gives up on multi-stage queries, so a native stage under an MBQL stage still printed its SQL in the unresolved fallback. The two now come back distinctly, and native is recognised by looking for a native stage anywhere in the query.
* BOT-1956: Take client ids back in the shape the app DB returns them
JSON has no sets, so state read back out of metabot_message carries the ids as a vector, and the agent entry point validates its input before memory initialization turns it back into a set. Every conversation with a stored turn therefore failed to start its next one under dev and test instrumentation. The state schema accepts either shape now and normalization settles it, and the test covers the round trip rather than handing a set straight in.
* Hide native SQL from the viewing context when perms can't be checked
* Use with-dynamic-fn-redefs in the sandboxed field transform test
* Hide native SQL from read_resource when perms can't be checked
* Treat an unresolvable database id as an unchecked query
---------
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
The Unofficial rail stops being a place to browse collections and becomes a
place to see what you are working on, matching the prototype's explore rail.
- Collections are now one row that slides a drawer out from the right edge of
the rail, floating over the content: the tree, other users' personal
collections and the create action all move into it. The drawer closes on
Escape, on clicking outside the rail, and on navigating — picking something is
the drawer's whole job, so it gets out of the way afterwards. The
`expand-collections-in-nav` setting is no longer read.
- Opening a question, model, metric or dashboard adds it to an Open list in the
rail, so several things can be held at once and switched between. Closing a
row only unlists it; the entity is untouched and reopening restores the row in
place. State is page-session only (`openNavItems` in the app slice), like the
section switcher.
- Models and Metrics are gone from Data, which is now just Databases. The browse
list pages stay routed; they are reached from content rather than from a
permanent nav row.
The app bar is gone. In its place the content area gets its own header —
where you are on the left, search and the account switcher on the right —
matching the prototype, where the rail runs the full height of the window and
each page carries its own header.
- New `AppPageHeader`, rendered once above the scrolling content for every
non-admin page, holding the collection breadcrumbs, the search button and the
app switcher. `getIsAppBarVisible` becomes `getIsPageHeaderVisible` and keeps
gating admin / Data Studio / Monitor / fullscreen / embedding.
- The New button moves to a footer pinned over the bottom of the rail, and the
Metabot button and git-sync controls move into the rail, all inside the
Unofficial half: that is the creation section, and the prototype's rail footer
belongs to creation too.
- The logo, the sidebar toggle and the whole open/close/collapse-by-pathname
machinery are deleted: `isNavbarOpen`, its three actions, `getIsNavbarOpen`,
`isNavbarOpenForPathname`, `useCloseNavbarOnMount`, and every caller that
closed the rail on mount or on entering a sidebar. The rail no longer changes
with the entity you are looking at.
- Consumers that positioned themselves around a collapsible rail now assume it
is always there: the bulk-action toast is always offset by the rail width, the
detail view always subtracts it from the viewport, and the query builder
header always uses its narrow-viewport layout.
Question lineage and the detail-view row nav went with the app bar; the
prototype puts that context in each page's own header rather than in app chrome.
`SearchBar` (the inline field used only by interactive embedding's app bar) is
left in the tree but is no longer reachable.
* Tidy up slackbot CSV upload handling
Report a file that cannot be staged on disk as one failed file rather than
letting the IOException escape to the async handler, which only logs and leaves
the Slack user with no reply at all.
Take the Slack client from the caller instead of re-reading the app token per
file, fetch the uploads Database once, build the upload target in kebab-case,
and express pre-flight failures as system messages so the API namespace stops
hand-rolling its own.
* Trim the upload docstrings and comments to their contracts
Drop the note about the upload module's private `allowed-extensions`: nothing here
relies on that set, so it would go stale silently. The `csv-file?` test asserts
`txt` is not uploadable instead, which fails if anyone widens the set.
* Polish Slackbot upload handling
* Test attachment-only upload failures
* Clarify Slackbot upload return contracts
* Regenerate the modules config after dropping the Database lookup
* Tighten the upload docstrings and comments
* Close the response stream when a redirect Location will not resolve
URI.resolve throws IllegalArgumentException on a malformed Location, and redirect-target runs
in the let binding that computes target, before the cond reaches either .close branch. A Slack
file host answering with a malformed Location therefore leaked the response stream.
Returning nil instead folds the case into the existing "Unexpected response" branch, which
closes the body.
* Cover remote attachments end to end, and say nothing when there is nothing to say
A message carrying only a file stored outside Slack had no route-level coverage: the unit
tests reached remote-file? and the history message, but nothing exercised the reply path
that replaced master's refused-files-message.
A file_share event may also carry an empty :files vector, which with blank text left
handle-message-file-share posting an empty Slack message. It now posts nothing.
* Name an unsupported file type, and stop suggesting a retry for failed uploads
Retrying a failed upload fails the same way. The 415 from create-csv-upload! is the one error safe and useful to
explain, so say the contents don't look like a CSV or TSV.
* Word the unsupported file type message to cover the filename too
create-csv-upload! also answers 415 for an extension it doesn't accept, such as data.CSV, so the message can't
blame the contents.
* Return only the upload history, and describe the size limit as the bot's own
* Show the upload module's own 4xx errors to the user
* Port the upload failure message cases from #81202 to upload-file!
* Count every attached file in the Slack upload counter, under what became of it
Size rejections, unsupported and remote files, and unavailable uploads were never counted, so the error label missed the most common outcomes. Also stop formatting the model id with %d in the success log, where a nil id would have turned an upload into a reported failure.
* Point the user at the server logs when an upload fails for an unexplained reason
Everything that reaches the generic message needs an admin to read the logs, as the #81202 wording said.
Ports the load-bearing parts of the consumption/creation split prototype.
Nav
- Two-segment Official/Unofficial switcher at the top of the main rail,
backed by a `navSection` app-state slice (page-session, not persisted).
Section is seeded from the URL for deep links, then user-driven, and
remembers the last path per section for the life of the page.
- Official renders the Library and official-collection trees with their
*items* hanging off each collection; Unofficial keeps Home, Bookmarks,
the collections tree, data apps and Trash. The Data links render in both.
- Official membership is the item's own collection being marked official or
the item living in the Library. Authority level does not inherit in
Metabase, so neither does this. There is no authority-level search
parameter, so the official half is filtered client-side.
Browse
- /browse/metrics and /browse/models get the prototype shell (icon + title,
dot-separated meta, description paragraph) via a shared BrowsePageLayout,
with the table inside a bordered panel and a header button that flips the
section. No tabs - the nav switcher is the control.
- Both tables gain a non-sortable Owner column. Model rows now open the
model detail page rather than the query builder.
Metric page
- The pill-tab bar is gone; the About route is the whole read view: headline
figure with prior-period and year-over-year deltas, chart, dimension pills,
then Definition / Breakdowns / Dimensions / Used by / History panels beside
a sticky fact rail. The six routes stay so Data Studio's re-hosting keeps
working, reachable through a Back to metric link.
- Used by embeds the existing EE dependency graph rather than a port of the
prototype's own graph.
Model page
- New /model/:slug/detail with Fields / Definition / Used by / History and
the same fact rail; the index no longer redirects to actions.
Avatars
- UserAvatar draws a deterministic DiceBear glyphs tile from a stable seed
instead of rendering initials on a brand-coloured div, so the same person
carries the same avatar everywhere. The bg colour plumbing it replaces is
removed, along with the now-dead colour helpers and userColor props.
Notes
- TrendInfo/AboutVisualization are deleted: the headline card now owns the
figure and its deltas, and printing both would have doubled the number.
- The metric dataset-value helpers moved to common/metrics so the metrics
feature can use them without importing from browse.
- ModelHistory/ModelUsedBy are local to the models feature because module
boundaries forbid models -> query_builder imports.
* Bump backend deps for open Snyk alerts; define core-provided deps once
Security fixes:
- athena: netty 4.1.137.Final -> 4.1.138.Final (CVE-2026-89044, HTTP request
smuggling in netty-codec-http). Stays on the 4.1 line because the Athena JDBC
driver's async AWS SDK path is not binary-compatible with 4.2.x.
- snowflake: pin zstd-jni 1.5.7-16. snowflake-jdbc-thin 4.0.2 pulls 1.5.6-5,
which has eight open out-of-bounds read alerts, all fixed in 1.5.7-14.
t-digest (CVE-2026-87822, CVE-2026-87962) is left alone deliberately: Snyk lists
no fixed version, everything from 3.1 up is affected, and it cannot be excluded
because kixi.stats.core requires kixi.stats.digest, which imports TDigest at the
namespace level. Both CVEs are in MergingDigest.fromBytes and need an
attacker-supplied serialized digest; we only ever build digests in-process and
never deserialize one, so we are not exposed. Those two alerts need a dismissal.
Per review feedback on #82261: driver uberjars are stripped of anything core
already ships (build.plugin-uberjar/prune-provided-libs!), so a driver
re-pinning a core-provided library has no effect on what loads, still lands the
artifact in that driver's generated pom.xml for Snyk to alert on, and adds
another place to bump. Replaced 22 such declarations across 7 drivers with
:exclusions on the transitive dep that drags them in, so the version is defined
only in the root deps.edn.
Verified by resolving every driver basis before and after and diffing the
effective shipped set (basis minus core-provided libs): only the two intended
security bumps move, every other driver is byte-for-byte identical. All 8
touched drivers build and pass verify-driver. Regenerated poms confirm all 15
core-provided artifacts are now declared in the root pom.xml and in zero driver
poms.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Update modules/drivers/clickhouse/deps.edn
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
* Exclude httpcore5 and bcutil-jdk18on to close scan-only version mismatches
Per review on #82518:
- httpcore5: databricks-jdbc-thin 3.4.1 declares 5.3.6 and libthrift 0.24.0
declares 5.2, while the root deps.edn pins 5.4.3. Only httpcore5-h2 was
excluded, so httpcore5 still landed in the generated databricks pom.xml --
and excluding it from the driver alone would have left libthrift's 5.2 in the
standalone POM for scanners to flag. Excluded from both, matching what
clickhouse and hive-like already do.
- bcutil-jdk18on: databricks-jdbc-thin declares 1.84 and snowflake-jdbc-thin
4.0.2 declares 1.82, but both resolve to 1.85 transitively via the root's
bcpkix-jdk18on 1.85. Same scan-only mismatch as bcpkix/bcprov, which were
already excluded; added bcutil alongside them in both drivers.
No change to what either driver ships -- these artifacts are core-provided and
already stripped from the driver uberjars by prune-provided-libs!.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
Sometimes you want to start a DB and connect your client directly to
mess around, but you haven't memorized the exact command. Why not make
mage give you a lil hint?
Sync deactivates a dropped table but leaves its fields active, so an FK
field on a dropped or archived table kept reporting
:incompatible-implicit-joins and blocked find and replace on the table
it targets. The incoming-FK check now requires the FK field's table to
be active too.
Fixes#82615
Move field sorting loader to end of row to avoid layout shift
The loader rendered before the sorting controls, pushing them right while
the field order request was in flight. Rendering it last keeps the
controls in place.
Fixes GDGT-3236
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Revert "Integration branch for Embedding settings reorganization (#80336)"
This reverts commit b9d217cbb4.
Also drop enable_embedding_modular from the closed stats snapshot
schema. #82447 (Require typed, closed argument schemas in mu/defn)
added that line after #80336, so git revert leaves it behind while
removing the key from the snapshot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014sqWaxii4kHFrYmW9c45zZ
* fix: keep writing embedding_theme.is_default
The is_default migration stays after the revert, since it may already
have run and migrations cannot be removed, so the column still exists.
- Restore its serdes :skip entry and the theme API key-set
expectations for endpoints that return the whole row.
- Keep marking seeded Light/Dark themes as default, so the flag stays
meaningful when the embedding hub work is picked up again.
- Keep returning it from the theme API, including the list endpoint,
as #80336 did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014sqWaxii4kHFrYmW9c45zZ
* refactor: keep the settings-components module
#80336 moved the settings page primitives into the shared
settings-components module. That move is not part of what is being
reverted, and later master work builds on it (#82389, #82462), so keep
it: restore the module, its lint entries, and point imports back at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014sqWaxii4kHFrYmW9c45zZ
* refactor: keep the setup guide rename
#79514 renamed the embedding hub checklist code to setup guide, which is
what the UI already calls it. It is a rename only: the API path, setting
names and persisted state keep their embedding-hub names. Re-apply it on
top of the revert so the name stays free for the embedding hub section
when that work is picked up again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014sqWaxii4kHFrYmW9c45zZ
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Use Title for EmptyState heading and keep heading role
* Use h1 for the Metrics page title
* Use h1 for the Data page title
* Use h1 for collection name and Title/Text in collection empty state
* Fix EmptyState spec to use the illustrationElement prop
* Add hidden h1 and use Text for home greeting and captions
* Use h1 for the Models page title and fix heading levels
- Page title: Title order={2} -> order={1} (parity with Metrics/Data)
- Empty state title: order={3} -> order={2} (avoids h1 -> h3 gap)
- Explanation banner: Title order={6} -> Text size="md" lh="sm" fw={700}
(banner copy is not a heading; removes h1 -> h6 skip, style-parity swap)
* Remove heading level unit tests
Drops the getByRole heading-level assertions added across the branch
(8 tests in 7 spec files, incl. the new EmptyState spec). Heading
semantics remain covered by the component changes themselves; e2e/a11y
audits own regression coverage.
* Address Copilot comments
* Update HomeCaption to use div
* Fix e2e test broken by HomeCaption's new wrapper element
HomeCaption now wraps its children in a Text component, which added
an extra DOM level. The custom-viz e2e test relied on .parent() from
the caption text landing on the section's shared ancestor with the
recent-item links, which no longer holds.
Scope the assertion via a stable data-testid instead of DOM depth, so
it doesn't re-break on unrelated markup changes.
* GDGT-3181: Address PR comments
* feat: add a host React major version helper to the SDK
Reads the major from the React the host app hands to the bundle, so
both the React 18 deprecation warning and the upcoming unsupported
React version guard read the same source.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtrkKRneS6NAuJmdYXEHHi
* feat: warn when the SDK host app runs React 18
The SDK will require React 19 in an upcoming release. Surface that
through the existing usage-problem banner and its console message when
the host app renders the SDK with React 18 on localhost or in the host
app's development mode. Production hosts get the same styled console
warning without the banner, and it ignores allowConsoleLog because
losing React 18 support is a breaking change for them. The iframe embed
is excluded since its React version is Metabase's own.
The new warning has the lowest precedence so license errors, the
API-key evaluation warning and the missing "exp" warning still show
when present.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtrkKRneS6NAuJmdYXEHHi
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RkRuMTMz2vB8zeicbr9nvq
* fix: log SDK usage problems found after mount
The usage problem is computed into the store in an effect, so on the
first render the hook still reads null. The log-once effect ran with
that null, logged nothing and marked itself done, so the real problem
that arrived a render later was never logged.
Only log once init has finished and a problem exists. Before init
finishes, settings still hold placeholder values, so a problem
computed then can be the wrong one, such as the licensed API key
warning on an unlicensed instance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RkRuMTMz2vB8zeicbr9nvq
* fix: show the React 18 warning before other problems
API-key users on localhost only ever saw the API key warning, so they
would not learn about the React 18 deprecation until they moved to SSO
or production. The React 18 case now comes first in the usage problem
match, right after the MCP app opt-out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep usage errors above the React 18 warning
Putting the React 18 warning first hid the license, disabled-SDK and
non-localhost API key errors on React 18 dev hosts, and let SDK
components render where those errors would stop them. Errors now come
before every warning, and React 18 stays first among the warnings.
The API key error gains `isLocalhost: false` so it can sit above the
localhost API key warnings without catching them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KsyRP238wpM1hKnPg2VRh2
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* add entity_id to glossary and key its serialization on it
* route glossary writes through glossary.core
* track glossary entries in remote sync when the Library is synced
* export and import glossary entries through remote sync
* tweaks
* lock glossary edits on read-only instances and backfill ledger rows on upgrade
* fe support
* tests
* resolve term-keyed glossary files to local entity ids during remote sync imports
* fix tests
* e2e adjustments
* fix test
* resolve term-keyed glossary paths without a shape check
* resolve same-term glossary files by term in the pre-load conflict check; fix migration
* Backfill glossary tracking per entry, not all-or-nothing
* fix test order
* Read the reference database pages from their own requests
* Remove the entity selectors from the reference store
* Pass the entity the reference update actions read
* Tidy the comments and docstrings from the search index metadata fix
The sql-errors registries carried a diary block about the change instead of a note per code.
Put each vendor's state above its entry, move the driver-alignment note next to the set it
guards, and put the vendor-code rationale on the cond branch that needs it.
Elsewhere: restore a dropped blank line, move the IF EXISTS rationale into the fn body,
and rewrap the comments and docstrings that broke mid-sentence.
* Name MariaDB alongside MySQL in the sql-errors comments
* Restore SQL error registry maintenance context
* Fix search index metadata deletion guard
Delete only the pending SearchIndexMetadata row rather than whichever row happens to match the coordinate, and
state that contract in the docstring.
The cleanups the fix leaned on, in the same change:
- Write the index DDL as Honey SQL maps instead of threading `sql.helpers`, which earns its place only when
clauses are added conditionally.
- Identify a missing index table by SQL state rather than exception class, covering every state our app databases
report, and name those states and the vendor error codes instead of spelling out literals at each call site.
- Take the connection as an argument rather than encoding it in an `-on-conn!` name suffix.
- Let the index poll survive an index that has not been built yet.
- Move rationale out of the docstrings into comments: a docstring is the caller's contract.
- Skip `with-model-cleanup`'s reindex when no search-relevant rows were deleted, and give `multiple-limits-test`
its own search data so it stops depending on another test's rows.
* State which safe-batch-upsert! failures throw and which are skipped
The docstring claimed failed retries propagate; only a missing table
after refresh, a missing table on the retry, and interruption do. Any
other failure is logged and the batch is skipped.
* Classify app-db SQL errors centrally instead of matching states in search
app-db.sql-errors keeps the named SQLSTATE and vendor-code registries and
classifies an exception's cause chain into an error class keyword
(:table-not-found, :duplicate-key) that call sites dispatch on. The
search module's private copy of the missing-table states goes away, and
with it the comment about keeping them aligned with the drivers; the
duplicate-key entries are ready for their caller in the lease work
stacked above. MySQL's catch-all integrity-constraint state is
disambiguated by the vendor code, so no db-type parameter is needed.
* Relax the reset pre-delete to any non-active index metadata
delete-pending-index! becomes delete-non-active-index!: a reset now also
clears retired metadata rows for the coordinate, never active ones. The
lifecycle test covers deleting retired and pending rows while the active
row is refused.
* Count every cleanup deletion and reindex when any rows were removed
The search-hooks filter had a blind spot: raw deletes cascade (a user's
personal collection), removing search-relevant rows even when the listed
model has none. Count the total with a reduce and rebuild whenever
something was deleted; only an empty cleanup skips.
* Address search index review feedback
* List every MCP v2 tool regardless of token scopes (GHY-4543)
tools/list filtered out tools the token lacked scope for, so a client
holding a narrow grant never saw the write, SQL or delivery tools and
had nothing to attempt or step up for. Scope stays enforced at
tools/call; the disabled-tools and client-extension filters still apply.
With scopes out of the listing, list-tools and tools-hash drop their
token-scopes parameter, and the transport's tools-hash-fn becomes
zero-arity.
* Explain MCP v2 scope denials to the model (GHY-4543)
Clients replace a scope denial with their own text ("token expired",
"Insufficient scope", "Tool execution failed"), so the model tells the
user their login expired. Server instructions now say that failure is a
missing permission, and how the user reconnects to grant it. Every tool
description ends with a static sentence naming its required permission by
its consent-screen label, identical for every token: Claude Code caches the
first description it loads for the whole session.
* Answer MCP v2 scope denials with 403 insufficient_scope (GHY-4543)
A scope denial was an HTTP 200 carrying a JSON-RPC error or an isError
result, so clients never stepped up: Claude Code only saves a step-up
scope, and mcp-remote only starts one, on a real 403 with an
insufficient_scope WWW-Authenticate challenge. The registry gate and the
four in-handler scope checks now mark their refusal, and the transport
answers a single (non-batched) message with 403 plus a challenge whose
scope is the held v2 scopes plus the required one.
* Keep a DCR client's registration scope from narrowing its step-up (GHY-4543)
Claude Code registers with the scope it read from the protected-resource
metadata, then steps up on the same client_id with the wider scope from a
403 insufficient_scope challenge. oidc-provider validates a requested scope
against the client's registered scopes, so that step-up was refused with a
400 invalid_request. Once scopes_supported is narrowed to the baseline, no
Claude Code user could reach write, SQL, or delivery.
A dynamically registered client's ceiling is now its registered scopes plus
the default grant scopes, computed on read. Registering can widen the
ceiling (a client that registers for mb:full keeps it) but never narrows
it. RFC 8707 resource narrowing is unchanged.
* Declare each MCP v2 tool's OAuth scope in securitySchemes (GHY-4543)
ChatGPT only steps up for scopes a tool declares: without per-tool
securitySchemes it re-authorizes for its narrow login scope in a loop,
and with them it re-authorizes once for held plus declared scopes.
The scheme is derived from the tool's static :scope in the cached
manifest, so it is the same for every token.
* Advertise only the baseline MCP v2 scopes to new clients (GHY-4543)
The protected-resource metadata `scopes_supported` and the 401 challenge
`scope` now list only `agent:content:read agent:resource:read`, so users
who never need write, SQL, or delivery never grant them. Claude Code and
the Claude connectors take their first-login scope from the resource
metadata; ChatGPT takes it from the challenge. All of them reach the rest
through the 403 `insufficient_scope` step-up and per-tool
`securitySchemes`.
What the resource accepts is unchanged: RFC 8707 narrowing still keeps all
six v2 scopes, and the authorization-server metadata stays wide because
Codex requests that list on every login and cannot step up.
* List every MCP v2 resource regardless of token scopes (GHY-4543)
resources/list now matches tools/list: it is token-independent, so a client can
see the resources it could step up for, and a client that pre-fetches the MCP
Apps shells before stepping up to agent:query:run does not cache them as
missing. resources/read keeps its scope gate.
* Answer MCP v2 resources/read scope denials with 403 insufficient_scope (GHY-4543)
resources/list now shows every resource, so answering a denied read with
"Resource not found" hides nothing and leaves a client with no step-up
signal. A client that pre-fetches an MCP Apps shell with a baseline token
now gets the same challenge tools/call sends, naming agent:query:run.
Unknown URIs stay -32602; inside a batch the denial is an in-band -32600.
* Lead MCP v2 tool descriptions with their required permission (GHY-4543)
Clients truncate long tool descriptions (Claude Code at 2048 characters),
so the appended "Requires the ... permission" sentence was cut from
document_write and execute_query. The model needs that sentence to name
the missing permission after a scope denial, so it now opens every
description, and the server instructions say it starts the description.
* Serve every MCP v2 resource on resources/read regardless of token scopes (GHY-4543)
Claude Desktop reads an MCP Apps shell alongside the tool call. When that
read was refused with 403 insufficient_scope, Desktop never stepped up after
the tool call's own 403; with the shell served, it stepped up automatically
and retried. The shell carries no data and, for a token without the shell's
scope, no UI credential: read-resource now withholds the credential instead
of refusing the read. Chart data stays gated by the tool call,
refresh_ui_credential, and the UI-surface route costs.
* Restore the scope check on MCP v2 data resource reads (GHY-4543)
Only the MCP Apps UI shells need to be served to any token: a refused shell
read stopped Claude Desktop stepping up after the tool call's 403. The fields
catalog carries data and has no such constraint, so reading it without
agent:resource:read again gets the 403 insufficient_scope challenge (in-band
-32600 inside a batch). UI shells stay unscoped, minting a credential only for
a token holding their scope.
* Fit MCP v2 tool descriptions under Claude Code's 2048-character limit (GHY-4543)
Claude Code truncates tool descriptions at 2048 characters, so document_write and
execute_query lost their closing guidance, including execute_query's pointer away
from execute_sql for filtered-dashboard cards. Condense both, and get_content for
margin, deferring detail to learn() topics and argument schemas. A test now holds
every listed description to the limit.
Also correct docstrings and comments left stale by the resources/read and
tools/list scope changes and by credential delivery through refresh_ui_credential.
* Spell out the scope union in the MCP v2 challenge docstring (GHY-4543)
The whitespace linter rejects the confusable Unicode character U+222A.
* Add agent:query:run to the MCP v2 baseline scopes (GHY-4543)
Claude Desktop retries a tool after a step-up over a session that does not
declare MCP Apps support, so a stepped-up visualize_query is refused and the
first chart after a step-up never embeds. Querying and charting therefore
must not require a step-up. The protected-resource metadata, the 401
challenge and default-ask-scopes now advertise agent:content:read
agent:query:run agent:resource:read; writes, raw SQL and delivery still step
up.
* Point MCP v2 chart requests at visualize_query in the server instructions (GHY-4543)
The routing line named only execute_query, and clients like Codex load tool descriptions on demand, so chart requests could miss visualize_query.
* Gate native query handles in transform_write on agent:sql:run and the kill switch (GHY-4543)
A query handle is not proof the SQL gates were spent: /api/embed-mcp/drills
stores a native query under agent:query:run alone. transform_write now runs
the same scope and mcp-execute-sql-enabled checks on a handle that resolves
to native SQL as it does on an inline native definition, matching
question_write.
* Tighten resource registration, step-up scopes, and the DCR ceiling (GHY-4543)
- register-resource! takes a closed map and strips :ui?, so a data resource
can never be served to a token without its scope.
- step-up-scopes matches held scopes instead of looking them up, so a token
holding a wildcard such as agent:content:* keeps the surface scopes it
covers when a client replaces its grant with the challenged scope. It now
takes the single required scope both callers pass.
- The default grant ceiling is applied only while dynamic registration is
enabled, so turning registration (or MCP) off stops widening existing
dynamic clients. The merge is now a pure function with unit tests, and an
HTTP test pins that static clients are never widened. The legacy-client
migration test runs with registration disabled, where the stored snapshot
alone decides, so it exercises the migration again.
* Correct MCP v2 tool descriptions, the documents skill, and server instructions (GHY-4543)
- document_write lists the valid entity-link models again.
- The documents skill said edits store new_str literally; the tool parses it
as Markdown, as edit-replacement-parses-markdown-test pins.
- execute_query no longer claims to save cards (its handles save as cards),
and names the previous-stage field ref shape instead of an ambiguous
column-name string.
- The server instructions attribute the rewritten permission failure to the
client and cover resource reads, which get the same 403.
* Pin the MCP v2 scope-denial and UI-credential contracts in tests (GHY-4543)
Closes gaps the review found, where removing a check would stay green:
- question_write and subscription_write in-handler denials carry
:insufficient-scope at the registry, like alert_write's.
- A denied tools/call sent as a notification is a bare 202, no challenge.
- Wildcard and unrestricted tokens get the shell credential; the fields
catalog mints none, over the registry and over HTTP.
- tool-and-resource-scopes-agree-test asserts the credential is realized,
since any token now reads a shell.
- advertised-scopes-are-distinct-test covers the advertised baseline, which
is a vector and can repeat, and its docstring says so.
* Clean up MCP v2 scope-denial helpers, header quoting, and docs (GHY-4543)
- Share one scope-denial message and detail builder between tool calls and
resource reads, one step-up challenge helper, and a common
throw-insufficient-scope! for the four in-handler scope checks.
- quoted-string replaces " and \ instead of backslash-escaping them, since
RFC 6750 excludes both from scope and error_description values; the 401
challenge now quotes through it too.
- Trim docstrings to their contract and move the reasons into comments.
- The unrestricted UI credential comes from any unrestricted session (cookie,
API key, or an mb:full token), not only cookie sessions.
- Note that the DCR ceiling applies only while registration is enabled, and
fix stale comments, test names and strings, long lines, order-sensitive
scope assertions, and hard-coded consent labels in tests.
* Always widen dynamic OAuth clients to the MCP scopes (GHY-4543)
Claude Code registers with the baseline scopes and steps up on the same
client_id. With dynamic registration turned off, that step-up /authorize
was answered with a raw 400. A dynamic client now always gains the six v2
MCP scopes on read, each still behind consent; the remaining agent-API
ceiling is added only while registration is enabled. Registration itself
stays blocked.
* Close the MCP v2 scope test gaps (GHY-4543)
Cover the transform_write update path's native gates, the HTTP 403
insufficient_scope challenge question_write and transform_write send from
inside their handlers, saving a drill handle minted through
/api/embed-mcp/drills, and the closed resource registration schema. Give
the locale test a positive control, merge the two tools/list
token-independence tests into one that compares the whole payload, and
drop the permission-sentence truncation test the fit and starts-with
tests already cover.
* Tidy MCP v2 scope wording and naming (GHY-4543)
Rename registry/insufficient-scope to insufficient-scope-detail so it
does not read like the transport fn of the same name. Drop the server
instructions clause telling the model to read a resource's scope off an
error clients hide. Document agent:resource:read and the handler-internal
scope checks in the module README, correct the stale comments about UI
resources gating on agent:query:run, and wrap the lines this branch left
over 120 columns.
* Let the test fallback template win over a local frontend build (GHY-4543)
The MCP Apps shell renders from the built `embed-mcp.html` when one exists,
so a worktree that has run a frontend build shadowed the inline fallback the
credential tests install. The production template has embedded no
`uiCredential` since #81041, so those tests failed locally and passed on CI,
where backend-only runs produce no build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QT1v8on6aPT2UeHCojGj8
* Gate the MCP grant-ceiling widening on the MCP kill switch (GHY-4543)
A dynamically registered client gained the MCP surface scopes on every read of
its client record, unconditionally. Only the wider `default-grant-scopes`
ceiling was gated, on `oauth-server-dynamic-registration-enabled`.
`agent:resource:read` is one of those MCP scopes and is also the declared scope
of `POST /api/agent/v1/read-resource`, which a separate lever gates. So with MCP
disabled and the agent API enabled, a dynamic client could authorize for a scope
its registration never included and spend it at a surface still serving.
Read `mcp-enabled?` in the effectful client-store wrapper and pass no MCP scopes
down when it is off; `widen-to-grant-ceiling` stays pure. While MCP is enabled
the existing behavior is unchanged: the MCP scopes are still added whatever the
registration setting says, so a client registered with the baseline can step up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QT1v8on6aPT2UeHCojGj8
* Cover the built MCP embed template's no-credential property (GHY-4543)
Serving MCP Apps UI shells to any token holder rests on the built
embed-mcp.html discarding the credential it is handed (since #81041) — the
iframe fetches its own through `refresh_ui_credential`. Nothing asserted that,
so a frontend change reintroducing a `uiCredential` placeholder would land
silently now that the outer scope gate on shell reads is gone.
Add `metabase.mcp.ui-resource-test` rendering the built template with a
credential and asserting the output carries neither the credential nor a
`uiCredential` placeholder. Backend-only CI runs have no frontend build, so the
test logs a skip instead of failing when the resource is absent.
Also gate the test fallback template's precedence over the built one on
`is-test?`, so the reorder can only ever apply in a test process.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QT1v8on6aPT2UeHCojGj8
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Accept localized names in CollectionInstance
The sidebar fails to load: any call passing the Trash collection through
`CollectionInstance` throws.
Invalid input: ["an instance of the root Collection, got: (toucan2.instance/instance
:model/Collection {... :name #metabase.util.i18n.UserLocalizedString{:format-string
"Trash" ...} :type "trash" ...})"]
`trash-collection` sets `:name` with `deferred-tru`, yielding a
`UserLocalizedString` rather than a `String`. #82447 added `CollectionInstance`
with `[:name :string]`, which that value cannot satisfy, so the Trash
collection matches neither branch of `[:or RootCollection CollectionInstance]`.
Malli reports only the first branch's `:error/message`, which is why the error
misleadingly names the root Collection.
Widen `:name` to accept localized strings via the existing
`ms/LocalizedString`. The deferral is deliberate: `trash-collection` is
memoized across requests, so eager-rendering with `str` would freeze the name
in whichever locale filled the cache (#59662). `:slug` and `:description` are
left alone; they are not localized.
Reproduced and verified against a running instance: `shared-tenant-collection?`,
`children-location` and `descendant-ids` no longer throw on the Trash
collection, and `GET /api/collection/tree`, `/api/collection`,
`/api/collection/:trash-id` and `/api/collection/:trash-id/items` all return
200 with the name rendering as "Trash".
* Reopen the cols schema in the static-viz color picker
Rendering a table with conditional formatting throws:
Invalid input: {:cols [{:display_name ["disallowed key, got: \"Category\""],
:base_type ["disallowed key, got: :type/Text\""]} ...]}
fn-name: cell-background-colors
`QueryResults` describes the value handed to the shared color-picking JS. Its
docstring says it is "a pretty loose schema, more as a safety net" that "just
needs column names from `:cols`" — callers pass whole column metadata maps and
only `:name` is ever read. #82447 closed the inner `:cols` map, so every caller
passing `:display_name` or `:base_type` now fails validation.
Mark that map `::mr/deliberately-open` to restore the documented intent. The
outer map stays closed; the extra keys only ever appear on columns.
* Add GPT-6 Astra to the OpenAI, Azure and Bedrock model pickers
The OpenAI allowlist stopped at the 5.6 family, and the reasoning/temperature
check keyed off a gpt-5 prefix, so a forced request would have sent
temperature, which Astra rejects. Widen the check to GPT-5 and GPT-6 and add
the model to the three adapters that sell it directly.
OpenRouter is left out: its allowlist needs a live reasoning probe per model.
* Correct Bedrock context window comment
* Give flat-table-content typed argument schemas so body.clj loads
#82447 made mu/defn throw at macroexpansion when an argument's schema does
not declare its shape. #82277 landed flat-table-content with an unschema'd
card argument and an unschema'd open map, so requiring
metabase.channel.render.body now fails to load.
Use the same argument schemas as its sibling prep-for-html-rendering, which
receives the same two values from `render :table`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QT1v8on6aPT2UeHCojGj8
* Narrow simple-pivot-content's color-data cols to :name
The second collision between #82447 and #82277: #82447 closed the cols
entries of js.color's QueryResults to exactly {:name :string}, and #82277's
simple-pivot-content hands cell-background-colors full cols, so every
simple-pivot-* test errors with :metabase.util.malli.fn/invalid-input.
Narrow the cols the way the flat-table path already does. The colors are
unaffected: the JS reads cols only to build a name->index map
(makeCellBackgroundGetter -> getColumnIndexesByName).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QT1v8on6aPT2UeHCojGj8
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add a kondo lint for unquoted interpolation into agent-facing prose
Teaching errors, paging hints, and steering lines read to an LLM as the
server speaking. A warehouse table name, driver error, or card name
interpolated into that prose unquoted can carry newlines and text that
pose as server-authored instructions.
`:metabase/unquoted-prose-interpolation` flags `format` %s arguments,
`tru`-family {n} arguments, and non-literal arguments of a `str` call
over a prose literal, unless they are literals, `pr-str` calls, or a
`str/join` over `(map pr-str ...)`. It is enabled only for
`metabase.mcp.v2.*` and `metabase.agent-lib.*` source namespaces.
The rule currently reports 243 findings; a follow-up tunes out the
noise and fixes the real sites.
* Scope the prose-interpolation lint to MCP v2 and the MCP transport
* Add the MCP v2 message builder and lint its lines
`metabase.mcp.v2.message` builds agent-facing prose: `msg` takes a
vector of line format strings plus arguments, and `render` joins the
lines with newlines and interpolates the arguments, cleaning each one
unless it is `raw` or a nested message. `clean` quotes with `pr-str`
and escapes invisible, line-breaking, and quote-like characters as
\uXXXX. `render` never throws: a malformed message, a format failure,
or anything that isn't a message renders with every part cleaned.
`:metabase/agent-message-lines` checks `msg` calls: the lines must be a
literal vector of string literals with no line breaks, `%n`, or
control characters, and the arguments must match the specifiers.
* Render MCP v2 messages at the tool-content and JSON-RPC exits
Teaching errors thrown with a message render it into the exception
message and carry the message in ex-data, so the caller-facing error
paths hand it on to be rendered. error-content, success-content, and
jsonrpc-error render messages; plain strings pass through unchanged for
now. message? is now public.
* Lint that MCP v2 exits receive msg-built text
Add :metabase/agent-message-exit, enabled for agent-prose-namespaces. It flags the message argument of throw-teaching-error, error-content, jsonrpc-error, and success-content when it is syntactically text (a string literal, a string-building call, or a threading, branching, or body form yielding one), and ex-info calls whose data literal carries a 4xx :status-code or an error-code key.
Point the unquoted-prose-interpolation messages at msg instead of pr-str.
* Scope the agent-prose lints in their hooks instead of with :off switches
The prose-interpolation and agent-message-exit linters are :warning globally; their hooks run only in non-test metabase.mcp.v2.* and metabase.mcp.transport namespaces. agent-message-lines runs in any non-test file. This keeps config.edn free of new :off switches for the kondo ratchet.
* Build shared MCP v2 prose with msg
Convert the shared response helpers, the transport's JSON-RPC errors, the
registry's pre-dispatch errors, and the API's method and resource errors to
msg. Registry errors now carry a message, humanized schema detail cleans its
paths and expectations, and the query pipeline's rewrapped schema failure
keeps the original message as a cleaned argument. Adds message-ex-info and
list-message to common.
* Build shared MCP v2 write, resolve, and skills prose with msg
Convert the _write dispatch, id resolution, recipient, projection, skills,
and recovery-hint text. Caller-supplied values (ids, clear names, methods,
recipients) are cleaned; server-declared names stay bare. The skills
catalog, pack, and reference texts are now messages, and the recovery
hints render msg-built English text for the shared pipeline.
* Build MCP v2 bookmark and UI credential prose with msg
* Build MCP v2 alert and subscription prose with msg
* Build MCP v2 transform prose with msg
* Build MCP v2 collection, duplicate, content, and document prose with msg
Card types, include sections, and document snippets are now cleaned in
teaching errors; the tools' own argument keys and type names stay bare.
The default copy name stays a plain content name, and get_content passes
its payload to success-content for encoding.
* Build MCP v2 metric, segment, measure, and question prose with msg
Normalizer and pipeline exception text, card types, template tag names,
column names, and schema summaries are cleaned in teaching errors. The
definition-shape and metric shape-rule sentences are messages nested into
the errors that end with them, so resolve-external-query receives a
message hint. The native-SQL scope refusal goes through
throw-teaching-error.
* Build MCP v2 parameter-values and dashboard-op prose with msg
Stored parameter ids and names, template-tag names and types, and text-card
placeholders are cleaned in teaching errors, listed with list-message. The
get_parameter_values steering lines are messages nested after the JSON
payload, including the shared truncation line. op-error! takes a message
detail; op and argument names the server declares stay bare.
* Build search, learn, and visualize MCP v2 prose with msg
* Build query and dashboard MCP v2 prose with msg
* Build browse MCP v2 prose with msg
* Name the op in dashboard op errors instead of in their detail
op-error! now takes the op name separately and renders `op <idx> (<op-name>): <detail>`, nesting the
detail message. Callers that still passed strings (the unknown-card check, add_link, and the unknown-op
fallback) rendered doubly quoted; the unknown-op fallback cleans the caller's unmatched op name itself.
* Clean plain strings whole at every MCP v2 exit
error-content, success-content, and jsonrpc-error now render everything
through message/render, so a plain string (a string teaching error, or an
upstream exception message surfaced by ->mcp-error-content or
caller-safe-error-message) reaches the client quoted and escaped instead
of passing through. Only msg values render as prose; "Internal error" is
now a msg so it stays unquoted.
* Scope MCP v2 prose linters by config and retire unquoted-prose-interpolation
Remove the format/str/tru interpolation checks now that msg cleans values at
runtime, restoring the i18n hooks to strict-apostrophes. msg lines may be split
with str over literals, and %s can't take width, precision, or flags, nor %S.
The linters are enabled for MCP transport and v2 source namespaces through an
ns-group instead of in-hook namespace checks.
* Harden MCP v2 message rendering, truncation, and rewrapped errors
clean escapes only double-quote look-alikes, keeping single quotes such as
U+2019 so names survive being copied back. render treats %S and %s with
flags, width, or precision as malformed, and renders a fixed sentence when
an argument can't be printed. ellipsize shortens string arguments inside
their quotes, falling back to message/truncate, which cuts a rendering
without leaving a quoted value open. An exception rewrapped with new text
surfaces that text instead of its stale ex-data message.
* Send MCP v2 recovery hints, throttle, and extension errors as prose
Recovery hints are now messages nested after the quoted pipeline text,
added by the v2 query entry point instead of appended by the shared
pipeline. The throttle refusal and missing-extensions error are
server-written messages rather than quoted strings, and the default
duplicate name is localized again with tru.
* Describe recovery-hint contract now that MCP v2 attaches its own hints
* Surface rewrapped exception text in MCP v2 query and definition errors
Use common/exception-message instead of queries' private copy, which
preferred the stale ex-data message over a rewrapped exception's new text.
* Restore plain MCP code restructured only for the retired prose lint
Inline the SSE framing and native summary prefix, return the WWW-Authenticate
builder and schema descriptions to plain str/format, and restore master's text
for developer-only load-time and 500 errors. Agent-facing output is unchanged.
* Tighten MCP v2 message docstrings to their own contracts
* Give MCP v2 message fns malli schemas
* Wrap long MCP v2 msg lines and build the learn catalog as one message
* GHY-4544: Widen agent-message lint to all MCP source and message-taking helpers
Rename hooks.metabase.prose-interpolation to hooks.metabase.agent-message.
Check msg lines in every non-test metabase.mcp namespace; keep the exit
check on transport and v2, whose 4xx errors are MCP exits. Flag malformed
format specifiers, treat cond/condp/case results as text, and check the
messages passed to op-error!, check-execute-sql-enabled!, ellipsize, and
list-content's :empty-hint. Split lint-msg into helpers.
* Never lengthen a quoted value when truncating a message
A value one character over its budget kept its full text and still gained an ellipsis, rendering longer than the uncut message. Cut values and whole values are now measured the same way, and the cut search never considers the whole value.
* Harden message cleaning and share render's formatting with truncation
clean binds the print vars that affect quoting, so a caller's *print-readably* false can't strip the quotes off a value, and is now private. More double-quote look-alikes are escaped. render and truncation share one formatting helper, and truncation helpers are named for what they return.
* Render schema failures as readable detail and add schemas to common message helpers
An invalid-input schema failure now names its problems with humanize-detail instead of printing the humanized data as one quoted value. message-ex-info, exception-message, list-message, and ellipsize are mu/defns, the message helpers move ahead of the teaching errors that use them, and pure helpers are named for what they return.
* Omit a missing exception message from MCP v2 errors and keep the original as cause
A nil exception message rendered as null (or an empty quoted string) in the
query pipeline rewraps and the definition, metric, transform, and inline
question normalization errors. Those messages now leave that text out.
with-schema-detail and with-recovery-hint rethrow with the original exception
as the cause, so logged traces show the pipeline's throw site.
* Unquote get_content item errors and name alert schedule fields readably
A get_content item's error is a JSON string value, so a plain upstream
message goes in as it is instead of quoted and escaped twice. alert_write's
uncompilable-schedule error lists the schedule's fields with their values
cleaned, instead of a double-printed map.
* Tidy MCP v2 message construction and naming
Send the throttle library's refusal as its own text, nest raw values
directly, inline duplicate's copy-name, rename query-failed and browse's
message locals, replace search's empty filler messages with a list join,
trim two Metabot docstrings to their contract, and mark two pure
dashboard_ops tests parallel.
* Judge threading forms by their result and check :empty-hint through browse's paged-list-content
The exit check read the threaded value of -> and ->> rather than what the form
returns. ->, ->>, some->, and some->> now take text-ness from their last step,
cond-> and cond->> from the initial value or any step, as-> from its last form,
and doto from its target; ex-message counts as building a string. The
:empty-hint check is also registered for browse's paged-list-content, which
passes its options map to list-content.
* Keep a truncated MCP v2 message's pieces so truncating it again closes its quotes
A truncation is now a message of the pieces it kept: server text as raw text and
a cleaned value as itself or its cut excerpt, so a later truncation cuts each
piece as the kind it is. Also registers the message and raw schemas, shares one
surrogate-safe string-prefix, merges the argument dispatch, and renders keywords
as their names.
* Wrap long lines in the MCP v2 common tests
* Pass server text to MCP v2 helpers as messages and name missing identifiers
Helpers that interpolated a string parameter through message/raw on the strength of a
comment about their call sites now take a message: alert and subscription scope and
schedule checks, parameter lookup, segment/measure name checks, duplicate's trash check,
alert id checks, and dashboard op errors. Rendered text is unchanged, except that
dashboard_write's unknown-card refusal now quotes the op name as the caller sent it.
A missing tool name, method, op, or dashcard/tab/parameter id now gets its own sentence
instead of rendering as null.
* Keep surrogate pairs whole when cutting document_write error snippets
* Wrap MCP v2 test lines longer than 120 characters
* Cut document_write error snippets with the shared surrogate-safe string-prefix
* Pass single-line server text to MCP v2 messages as raw
A message with one line and no arguments that is only ever interpolated into another message renders the same as raw text, so those call sites now pass (message/raw "..."). Messages that reach an exit, render on their own, or are returned as a top-level message keep msg. Helper docstrings now describe those parameters as server text.
* Inline one-use MCP v2 message helpers
require-hour! in alert_write and subscription_write and catalog-line in skills existed only to keep message-wrapped lines short. With raw text at the call sites they read as direct calls again.
* Allow a plain subject for the MCP v2 execute_sql kill-switch check
The helper interpolates its subject into its own msg, so a string literal is cleaned like any value.
* Minimize raw in MCP v2 prose
Identifiers interpolated into agent-facing messages are now cleaned rather than
passed as raw; raw moves to where server text is defined. Leftover call-site
comments are removed, lists of names render via list-message/lines-message, and
test expectations are updated to match.
* Quote field names in MCP v2 errors and clean remaining identifiers
Field, argument, and tool names in fixed agent-facing msg lines use double quotes
instead of backticks, matching names interpolated through cleaning. Code snippets
keep their backticks. common.clj cleans the not-found model name, the schema-failure
function name, select-fields type names and nearest catalog paths, and the
truncation-line param instead of passing them raw. alert_write's ignored-field error
says "An" before an hourly schedule, as subscription_write does. Test expectations
updated to match.
* Close MCP v2 message arg schemas for mu/defn's shape check and name not-found types as server text
* Allow only %s, %d, and %% in MCP v2 msg lines and govern enterprise MCP namespaces
Match the runtime's allowlist: a `msg` line may use `%s` with no flags but `<`, `%d` with flags and
width but no precision, and a bare `%%`; any other conversion would alter or hide a cleaned value.
Flag private-use characters too, since the truncation markers delimit arguments with them, and cap
the argument index at nine digits so an absurd one reports as malformed instead of overflowing.
The two MCP ns-groups now also match `metabase-enterprise.mcp.*`, which they left ungoverned.
* Bound MCP v2 message printing to 100 elements and 10 levels
An unbounded sequence argument made render hang and a multi-million-element vector rendered
megabytes of text, since printed pinned *print-length* and *print-level* to nil.
* Take only strings as MCP v2 raw message text
A non-string raw was interpolated into the formatted template instead of being kept as a text
piece, which could collide with the private-use truncation markers. Message lines holding
private-use characters are now malformed for the same reason.
* Allowlist MCP v2 message format conversions and widen quote look-alikes
Only %%, a plain %s, and a %d without precision format; every other conversion — %b, %h, %c,
%x, %f, %tY and friends, which reinterpret an already-cleaned argument — makes the message
malformed, so it renders fully cleaned. The double-quote look-alike set gains the ornaments,
ditto mark, and prime family found by sweeping the Unicode blocks it draws from.
* Clean get_content per-item error text instead of trusting JSON quoting
JSON escapes control characters and double quotes but leaves category-Cf
characters (a bidi override, a zero-width space) intact, so a plain-string
caller-facing error reached the model raw. Render it like every other agent
message, which quotes and \uXXXX-escapes the whole string.
* Wrap MCP v2 message lines the branch pushed past 120 columns
Text is unchanged: each line splits into a `str` of the same pieces, or moves
a word to the continuation line.
* Flag msg lines whose argument reference String/format can't resolve
The kondo hook counted a leading %< and an explicit 0$ as consuming no
argument, so a msg using either linted clean while the runtime's format
threw and fell back to rendering every part quoted.
* GDGT-2751 Apply the Table viz's pivot toggle in subscriptions
A Table card with the "Pivot table" toggle on arrives flat in a
dashboard subscription or alert. The toggle is a browser-only
transform; the three settings reach the static renderer, which
never read them.
Port the browser's pivot into `render :table`, including its sort
rule, and draw the grid with the existing pivot renderer. The grid
is built from every row and is not cut at the attachment row limit,
like a Pivot Table card. The server-side dashboard PDF gives such a
card the pivot treatment too, so it no longer prints the source row
count under the grid. CSV and XLSX attachments stay flat; they are
a separate code path.
3 functions needed to be moved. A pure move, no changes:
`setting-value`, `pivot-cell->str` and `pivot->hiccup` go from
the pivot section to a section of their own under the
`render` multimethod.
* Fix SDK entity navigation dropping click behavior filters on GUI questions
* Give ad-hoc question targets their own click behavior type
* Cover the GUI click behavior target with an e2e test
* Fix the internal-navigation dashcard setup clobbering itself
* GDGT: migrate TabRow to CSS modules to fix gradient theme issue
* Update Loki Snapshots
* increase scroll gradient size
* Update Loki Snapshots
* Add dark theme story for narrow dashboard with many tabs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Add placeholder Loki reference for dark theme narrow tabs story
Copy of the light theme snapshot so the loki-update workflow detects a
diff and replaces it with the CI-generated image.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Update Loki Snapshots
* move left/right styles into existing classes
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Require app-DB value slots to hold bound parameters
HoneySQL resolves a map in a value slot toward SQL structure rather than
data, so a value can reach the database as syntax instead of as a bound
parameter. `[:param ...]` is the only form that always binds.
Adds `metabase.app-db.value-guard`:
- `auto-param` rewrites inline `[:param* v]` markers into HoneySQL's
`[:param :kN]` plus the params map, so callers do not keep a separate
map in sync.
- `long*`/`longs` coerce ids, which covers the common lookup without
param noise and rejects anything that is not a number.
- `assert-values-wrapped!` walks the compiled query at the Toucan
compile step and throws when a value slot holds something that could
become SQL.
The walk is clause-aware: it descends only into `:where`/`:having`/`:set`/
`:values`/`:join-by` and knows which argument of each operator is a value,
so column and operator positions are left alone. An operator that is not
classified fails closed.
This is an allowlist and lives apart from `honeysql-guard`, which is a
blocklist of known shapes and is backported to older versions.
Two forms needed handling beyond plain scalars and params: h2x wrappers,
which are checked by their payload rather than trusted by their wrapper,
and HoneySQL's `:%fn.col` call shorthand, which is a closed enumerated set
rather than a prefix test so that a constructed keyword cannot pass.
`strict-modules` is empty for now. A module joins it once its call sites
carry params or coercions, at which point plain scalars are rejected there
too.
* Accept java.time values in value slots
Toucan's before-insert hooks add created_at/updated_at as ZonedDateTime,
which clojure.core/inst? does not cover, so every insert was rejected.
All java.time.temporal.Temporal values bind as parameters.
* Check nested values, report the offending leaf, defer installation
Three changes, all from measuring the check against a real module.
A dev-authored marker on a subquery asserts that its SQL is dev-authored.
It says nothing about the values inside it, which are just as reachable
from a request, so the check now descends into a marked subquery instead
of passing it wholesale. `honeysql-guard` also descends here, but with a
blocklist, so a nested unwrapped value passes it today.
When a nested value was rejected the error named the enclosing subquery
rather than the value, which pointed debugging at the wrong place. The
nested check now throws on the leaf.
The check is no longer installed at the Toucan compile step. Against the
`collections` module it errored 91 of 148 tests, and none of those was an
unparameterized user value -- they were correlated subqueries, a UNION
used as an operand, and SQL function calls in `:set`, all of which are
real SQL that happens to sit in a value-bearing clause. Installing it
globally is therefore not the right first step; per-namespace opt-in is,
and that opt-in has to key off the calling namespace rather than the
model, since every Toucan model keyword is `:model/Something` and carries
no module information.
* Adopt the value check in the content-translation queries
Gives the check one real caller instead of leaving it unused. `locale`
reaches `translations-for-locale` from a request query parameter, so it is
coerced where it enters the query and the query is checked before it runs.
Adoption is an ordinary function call at the site it protects -- no compile
hook, no ambient state -- so the next namespace adopts independently and
nothing changes for namespaces that have not.
Adds `str*` alongside the id coercions. It asserts a value is already a
string, which rejects the maps and keyword vectors HoneySQL would compile as
SQL while leaving a legitimate string untouched.
`checked` deliberately does not demand bound parameters. A coercion returns
a plain scalar, indistinguishable at runtime from an unwrapped one, so that
stronger property cannot be asserted from the compiled form.
Also records a blocker found while writing this: `[:param ...]` needs its
values in a separate `:params` map, and `honeysql-guard` rejects any unmarked
nested map, so that map never reaches HoneySQL. `[:param]` is therefore
unusable through Toucan until `honeysql-guard` learns about `:params`, which
is a change to a backported namespace and is left alone here.
* Scope the value check to namespaces that have adopted it
Runs the check inside Toucan's compile step again, but only for queries
issued from a namespace listed in `enforcing-namespace-prefixes`. A query
from anywhere else compiles exactly as before, so a namespace adopts on its
own schedule and nothing has to be switched on everywhere at once.
The alternative was a `checked` call at each query site, which only works as
long as everyone remembers to write it. Putting it in the pipeline means an
adopting namespace cannot forget.
Scoping by namespace rather than by model because a model is shared: opting
in `:model/Card` would opt in collections, search, dashboards and
serialization together, which is the big-bang this is trying to avoid. A
`db.clj` is the unit a team actually cleans up, and a prefix entry covers a
whole module once it is ready.
`enforcing-caller?` reads the call stack rather than an ambient binding. The
check runs several frames below the caller inside Toucan, so a dynamic var
would have to be threaded through Toucan internals and would not survive a
thread hand-off; the stack already carries the answer. Measured at ~4us per
query against a DB round trip of hundreds, and only paid at the compile step.
`enforcing-namespace-prefixes` is public so a clj-kondo hook can read the
same set and check adopted namespaces at author time.
Tests prove both directions: a query from the adopting namespace is rejected,
and the same query from a non-adopting namespace is not. A bare keyword in a
value slot is the discriminator, since `honeysql-guard` permits it and this
check does not. `collections` stays at the baseline 1958 assertions, 0 errors.
* Bind values inline with [:auto/param ...]
Makes the design doc's ergonomic half real. A caller writes the value where
it belongs and `bound` lifts it into the params map HoneySQL wants, so the
two never have to be kept in sync:
(bound [query {:where [:= :locale [:auto/param locale]]}]
(t2/select :model/ContentTranslation query))
Two things had to be fixed for this to reach the database.
`honeysql-guard` rejected the `:params` map as an unmarked nested map. It
now treats `:params` like `:set` and `:values` -- keys that hold values
rather than nested queries -- so its contents are still checked. Nothing in
`:params` can become SQL regardless of shape, since HoneySQL binds it as a
parameter, so checking it catches a value JDBC would later refuse rather
than an injection.
HoneySQL also takes params as a format option rather than a query clause, so
`bound` passes them through `toucan2.honeysql2/*options*`.
The marker is `:auto/param` rather than a bare keyword so it cannot collide
with a HoneySQL operator, and an unresolved one now throws: HoneySQL would
otherwise compile `[:auto/param v]` into a `PARAM(?)` function call, so
forgetting `bound` would produce wrong SQL instead of an error.
* Lift [:auto/param ...] in the pipeline instead of at the call site
A caller now writes the marker inline and nothing else:
(t2/select :model/ContentTranslation
{:where [:= :locale [:auto/param locale]]
:order-by [:msgid]})
The compile step lifts every marker into the params map and hands it to
HoneySQL, so the query is an ordinary Toucan query and the wrapper macro is
gone. One less thing to remember, and a query that forgets the wrapper is no
longer possible to write.
The method is `:around` rather than `:before` because it has to replace the
query it passes on, not only inspect it.
An unresolved marker still throws. It now means the query reached the
database by some route other than the compile step, since anything through
the pipeline gets lifted.
* Pin what an unlifted marker does, and correct why a hostile value is refused
HoneySQL does not recognise `:auto/param`, so a marker handed straight to
`sql/format` compiles as a call to a function named PARAM over its argument.
The database then refuses the statement -- `Function "PARAM" not found` --
so it is broken rather than dangerous, but it is worth pinning, since the
marker only does its job on the path that lifts it.
Also corrects a test comment that credited JDBC with refusing a hostile
non-scalar. Through Toucan it never reaches the driver: `honeysql-guard`
rejects the unmarked map at the compile step first.
* Check every clause that can hold a value, not a list of five
The walk inspected `:where`, `:having`, `:set`, `:values` and `:join-by`.
HoneySQL has about 92 clauses, so that missed real ones -- notably join ON
conditions, which are the most used clause family in the codebase (~650
sites across `:join`, `:left-join` and friends), plus `:do-update-set` and
`:on-duplicate-key-update`.
Worse than the omissions was the direction. Unknown *operators* failed
closed, but an unlisted *clause* was skipped silently, so a clause nobody
had classified went unchecked. The walk now lists the clauses that hold only
structure -- `:select`, `:from`, `:order-by`, the DDL -- and checks
everything else, so a clause added to HoneySQL later is inspected rather
than ignored.
A join clause is a flat sequence alternating target and ON condition, and
both are keyword-headed vectors. The operator table tells them apart: a
condition starts with a known operator, anything else is a table.
Also drops `long*`, `longs` and `str*`. They were `clojure.core/long` and
`str` with docstrings -- an alias layer with no added behaviour, and nothing
used them once the marker existed. `[:auto/param]` is what the walk can
actually see; a coercion returns a plain scalar it cannot distinguish from
an uncoerced one. GHY-4475 wants amending to match.
* Load the check from app-db core, and move test scaffolding out of src
Removing the `keep-me` no-op exposed a real gap: nothing loaded this
namespace, so neither the marker lift nor the check ran in a running system.
It was masked while the adopting namespace still required it. `app-db.core`
now requires it the way it requires `honeysql-guard`, and `keep-me` is a
reference inside `comment` rather than a defn, matching the convention.
The test-only query that proved the check is scoped to its caller no longer
lives in `src`. The test namespace adds itself to the enforcing set instead,
which works now that the munged class prefixes are derived on each call
rather than captured once at load; two copies of that set could drift.
Adds a test that an unmarked string is compared as a literal. A string was
never the danger -- HoneySQL binds it -- and saying so keeps the marker's
purpose clear: it is for values that are not strings, which HoneySQL would
otherwise compile as structure.
Records that the marker belongs in a query map rather than a kv-arg. Toucan
builds kv-args into the where clause after the lift runs, so a marker passed
that way ends up inside a PARAM call instead of a comparison.
* Reduce to the value binding, and drop the walk
Review found the walk failing open in every position it did not explicitly
classify -- a map reaching `check-clause!` returned nil rather than throwing,
which left `{:where [:and [:= :a 1] {:raw "1=1 OR true"}]}` compiling to
`WHERE (a = ?) AND (1=1 OR true)`. The left-hand side of every comparison,
arguments past the declared indexes, `:join-by` conditions, and
`:limit`/`:offset`/`:fetch` were all unchecked for the same reason. A check
that advertises a guarantee it does not provide is worse than no check, so
the walk is gone rather than patched. It can come back built fail-closed.
What remains is the part that worked: a `[:auto/param v]` marker is lifted
at the compile step into HoneySQL's `[:param :kN]` plus its params map, so a
value written inline binds as `?`.
Four bugs in that half are fixed. `postwalk` descended into a marker's own
payload, so a nested marker bound a `[:param]` vector instead of the value;
it now uses `prewalk` and takes the payload whole. Toucan re-enters `compile`
with the `[sql & args]` vector it produced, so the method ran twice per
query; it now only walks a map. Param keys were sequential and guessable,
letting a `[:param :p1]` from request data bind another slot's value; keys
are now random. The options binding replaced `:params` wholesale, dropping an
enclosing compile's params; it merges.
`honeysql_guard.clj` is reverted. `:params` is never a key in a query map --
it reaches HoneySQL as a format option -- so that change was dead code in a
file backported to v58, v61 and v63, and the comment claiming `[:param]`
needed it was wrong.
* Reject a malformed marker, and do not treat a map entry as one
A marker-headed vector of the wrong arity now throws. HoneySQL does not
recognise the leftover form and compiles it into a call to a function named
PARAM, so `[:auto/param v extra]` previously failed at the database with an
error pointing nowhere near the typo.
A `MapEntry` is a two-element vector whose first element is its key, so the
map `{:auto/param v}` would otherwise look like a marker and the map holding
it would be rewritten away. That map means "a column named :auto/param", so
reaching it takes misuse rather than ordinary use -- but excluding the type
costs one check, so the walk does it rather than leaving the shape to chance.
Testing `vector?` instead of `sequential?` does not help here, since
`MapEntry` implements `IPersistentVector`.
This also drops support for a marker written as a seq. Nothing writes one,
and the looser test was widened for surface area rather than a caller.
Adds tests for the write path and for a query with no markers, neither of
which the unit tests reached: they exercise `auto-param` directly, so the
pipeline method's own branches were untested.
* Accept one marker shape, and never let one reach SQL
`[:auto/param value]` is the one supported form. Anything else headed by the
marker -- a seq, the wrong arity -- now throws instead of being left to
compile, because HoneySQL does not recognise the keyword and turns whatever
is left into a call to a function named PARAM that fails at the database
with nothing pointing back to the query.
Two assertions hold the line. Nothing marker-shaped may survive the rewrite,
and every value lifted out has to come back as a bound argument: a marker in
a slot HoneySQL formats as an identifier is rewritten but never consumed, so
`{:from [[:auto/param :t]]}` silently produced `FROM param AS p3lg...`,
dropping the value and putting the generated key in the SQL text. That now
throws.
A marked nil is left to HoneySQL rather than bound. It turns a literal nil in
a comparison into `IS NULL`, while a bound parameter gets `= ?`, which no row
satisfies -- so wrapping a nullable value silently returned nothing.
A `MapEntry` is still excluded from the marker test, so a column named
`:auto/param` is left alone rather than rewritten away.
* Look for the marked value among the arguments, not for a count
`assert-every-value-bound!` compared how many values were lifted to how many
arguments came back, which any arguments satisfy -- not specifically the ones
that were lifted. A query carrying unmarked literals has arguments to spare,
so a dropped marker hid behind them:
{:from [[:auto/param :t]] :where [:and [:= :a 1] [:= :b 2]]}
;; => FROM param AS p16vl... WHERE (a = ?) AND (b = ?) -- two args, one
;; param, count satisfied, value silently gone
It now looks for each marked value among the arguments. A sequential value is
spread across one argument per element, so its elements are looked for rather
than the collection; anything else, including the map this exists to
neutralize, binds whole.
Tests cover the shapes where the number of arguments and the number of marked
values disagree: a collection, the same value marked twice, and marked values
sitting alongside unmarked ones.
* Drop the bound-value assertion, and say what is not checked
`assert-every-value-bound!` rejected ordinary queries. It inferred from a
value's Clojure type whether HoneySQL would spread it across several
arguments, but that is a decision HoneySQL makes per operator: `:in` spreads
a collection, `=` binds one whole. So a vector compared with `=`, and a set
in an `:in`, were each checked the wrong way and threw. A collection holding
`nil` or `false` threw as well, because the check used a set as a predicate
and a set returns nil for those members even when it contains them.
It was also unsound in the direction it existed for. Comparing against the
argument list loses which argument came from which value, so a dropped
marker passed whenever an unrelated literal elsewhere happened to equal it:
`{:from [[:auto/param "de"]] :where [:= :msgid "de"]}` was accepted, with the
value gone and the generated key left in the SQL as an identifier.
No post-hoc check can be sound here -- HoneySQL gives no signal for a param
it did not consume -- so the namespace docstring now states the limitation
instead of a check pretending to cover it. A marker still may not survive the
rewrite, and a malformed one still throws; both of those are about the marker
itself rather than about what HoneySQL did with it.
* Lint a converted namespace's values for a missing marker
Marking a value does nothing to keep it marked: a later edit adding an
unmarked value slot to a converted namespace passes silently. This adds the
author-time check that holds the line, extending the hook that already
enforces `db.clj` confinement rather than adding a second one -- the two are
halves of the same rule, and that hook already knows which namespaces are
`db.clj`.
It reports a symbol reaching a value slot without a marker, following the
clause shapes a query map uses. Only a symbol: a literal cannot carry a
request value, and a value built inside the function is out of reach of a
check that does not follow it across a call. Only a `db.clj`, which is what
makes it decidable -- the values there are named in the parameter vector a
few lines up.
Off by default, enabled per namespace. Run globally it reports 269 sites, of
which 196 are ids that Malli already constrains to an integer and that
therefore cannot become SQL. Warning about those would bury the 73 that
matter -- strings and enums like `perm-type`, `setting-key`, `namespace`. A
namespace is added to `:config-in-ns` once its own values are marked, so the
list grows with the migration rather than ahead of it.
`metabase.content-translation.db` is the first entry. Removing its marker now
fails the lint.
* Bind a marked kv-arg, which is how queries are actually written
`(t2/select :model/X :locale [:auto/param v])` did not work, and kv-args are
how nearly every query in the codebase is written: 1125 call sites across
`*/db.clj` against 3 that pass a query map. So the marker covered the style
almost nobody uses.
Toucan builds a kv-arg into the where clause before the compile step sees it,
taking the marker keyword for the operator and folding the column in, so
`:locale [:auto/param v]` arrives as `[:auto/param :locale v]`. That arity
was previously rejected as malformed. It is now recognised and rewritten to
`[:= :locale [:param k]]`, which is the comparison Toucan would have built.
A marked kv-arg holding nil still compares as `IS NULL` rather than binding,
matching an unmarked one.
The lint reads the same style now. With it enabled across `src` it reports
1006 sites rather than the 269 it saw when it only walked query maps, of
which 769 are id-shaped and 237 are not -- close to the 78% the project's
estimate predicted, and the 237 are the ones that need a marker rather than
a coercion.
* Pair kv-args from the model, and do not lint written values
Several query fns take an argument before the model -- (t2/select-one-fn
:value :model/Setting :key k) -- so pairing the kv-args from a fixed offset
misaligned them and the values went unreported. They are now paired from the
model keyword, which is where the pairs actually start.
Insert calls are no longer linted for unmarked values. A value written into a
row is not a where-clause value an attacker can turn into SQL structure, and
a define-before-insert hook may read it before the query compiles, so a
marker there breaks the write rather than protecting anything.
* Reject a marked operator form, and check every argument of one
A kv-arg value may itself be an operator -- `:locale [:in [...]]` -- and
Toucan folds that into `[:auto/param :locale [:in [...]]]`, which the kv
rewrite turned into `[:= :locale [:param k]]`. That binds the operator form
as a single value and changes the comparison, so `IN (?, ?)` became `= ?`
against a list. It now throws and points at the working form, which is the
marker inside the operator rather than around it: `[:in [:auto/param vs]]`.
The linter examined an operator's arguments only when there were exactly
two, so `[:between :col lo hi]` and any other arity went unchecked -- a false
negative, which is the expensive direction for this lint. It now takes every
argument after the column, and `between` joins the operator set.
`read-call?` no longer assumes the call head is Named. Kondo always passes a
resolved var so this was unreachable, but the failure would have been a
linter crash rather than one bad file.
Also drops a duplicated paragraph that described the key generation as
gensymed when it uses ThreadLocalRandom, and replaces the ns docstring's
description of a misplaced marker with the statement it actually produces.
* Lint the query shape as well as its values, under one finding
A query written as `:column value` pairs is now reported alongside a value
that reaches a slot unmarked, under a single `:metabase/unsafe-app-db-query`
finding. A namespace opts into both at once, and the pair can be flipped on
globally when every `db.clj` has been converted.
Toucan builds kv-args into the where clause itself, so the query's shape is
not on the page -- there is nothing for a reader, this linter, or the `.sql`
extraction that follows this project to read. Every call has a map form:
`{:where [...]}` for a select, and for `update!` a conditions map, since its
spec takes `conditions-map-or-query?` rather than only pairs.
Checking the shape rather than the pairs' values also removes the reason the
marker had to understand a kv-arg at all. The value check now walks query
maps only.
* Redesign the LDAP settings page and its group mapping card
* Keep the mapping editor fields aligned when a name is rejected
* Merge the LDAP group mapping e2e flows into one
* Address the LDAP page review feedback
* Drop comments that restate the code
* Lock the LDAP attributes card and group the locked cards
`handle-circular-refs-test` has been failing intermittently since #82447.
The last assertion wraps `(umd/describe :metabase.lib.schema/query)` in a
500ms `with-timeout`. That timeout exists as a smoke test against runaway ref
expansion -- before `max-depth` was added to `metabase.util.malli.describe`,
describing this schema took literal minutes. It was never meant to be a
performance budget.
#82447 required closed map schemas in `mu/defn`, which enlarged the schemas
`describe` walks to its `max-depth` of 3. Since expansion cost is roughly
exponential in branching factor, this made the call ~4.5x slower. Measured on
one machine, isolated runs:
before #82447: 89ms cold, 33-50ms warm
after #82447: 232ms cold, 158-193ms warm
Headroom against the 500ms budget dropped from ~14x to ~2.8x. The test is
`^:parallel` and `with-timeout` runs its body in a `future`, so it competes for
the thread pool with every other parallel test; under CI contention ~175ms
crosses 500ms. That is why it presents as flaky rather than consistently red,
and why it showed up on the Postgres 14 and Java 25 suites -- those runners are
slower/more contended, not semantically different.
The describe output itself is unchanged (identical 187-char string), so only
speed regressed, not correctness.
Raising the timeout to 5000ms keeps the runaway-recursion guard meaningful (that
failure mode is minutes, not milliseconds) while surviving pool contention.
Not treating the 4.5x as a perf bug to chase: `umd/describe`'s only production
caller is the error-explanation path in `metabase.api.macros`, reached only
after validation has already failed, and it describes the nested sub-schema
rather than the full query schema. Validation itself is unaffected by #82447
(sub-microsecond before and after).
* Build the public question from the store
* Pass the expression editor its database, not the Metadata object
* Build the FieldValuesWidget test metadata with createMockMetadata
* Name the endpoints the public question's parameters come from
Refuse to save a query handle carrying bound parameters
A handle minted by execute_sql re-attaches the :parameters it ran with, but
:parameters is runtime-only and serialize-query strips it on the way into
dataset_query. Saving such a handle persisted the query without its filter and
without complaining, so the card returned rows the agent's own run excluded.
Fail closed in resolve-query-handle-for-save!, the chokepoint shared by
question_write, metric_write and transform_write, with a teaching error naming
the concrete alternative.
Closes GHY-4547
* Send the SAML SessionIndex in single logout requests
Metabase's LogoutRequest identified the session to end by NameID alone, which
is the user's email. Auth0 issues a distinct user ID per connection, so one
email can own several sessions and Auth0 cannot tell which to end:
client_error: No active session(s) found matching LogoutRequest
SAML Profiles 4.4.4.1 makes SessionIndex the element that scopes a
LogoutRequest to a session. Record the SessionIndex the IdP sends in its login
assertion on the session it creates, and send it back at logout.
SessionIndex is per session, not per user, so it lives on core_session rather
than auth_identity, which has a unique constraint on (user_id, provider) and
would let concurrent logins overwrite each other.
A hardcoded SessionIndex was tried once and broke Azure AD (#41600). Only an
index the IdP itself issued is ever sent; sessions without one (every session
predating this column, and every non-SAML session) produce exactly the request
they produce today. The existing saml-logout test, which asserts on the full
signed logout URL for a session with no SessionIndex, passes unchanged.
Requires saml20-clj 4.3.0, which parses SessionIndex out of login assertions
and can emit it in a LogoutRequest.
Fixes#77837
* Send the IdP's NameID, not the user's email, in logout requests
An IdP matches a LogoutRequest on NameID plus SessionIndex. We sent the user's
email as the NameID, but the IdP's NameID is not necessarily an email: Auth0
sends an opaque `auth0|<id>`. A LogoutRequest naming an identifier the IdP never
issued matches no session, so Auth0 answered:
No active session(s) found matching LogoutRequest
even once we started sending the correct SessionIndex.
Record the NameID and its Format from the login assertion alongside the
SessionIndex, and send those at logout. Sessions predating these columns have a
NULL NameID and fall back to email, so logout keeps working across an upgrade.
Verified end to end against a live Auth0 tenant: the LogoutRequest now carries
NameID `auth0|<id>` with Format `unspecified` and the assertion's SessionIndex,
Auth0 returns StatusCode Success, and the session row is deleted.
* Manage Unsynced Content modal
* add actions
* Show dependies when attempting to remote sync
* adjust spacing of dependency rows
* handling snippet and dashboard dependencies
* reshaping API response for FE consumption
* save from modal again
* cleanup
* PR Feedback, Design Feedback
* handle instance analytics case
* fixing function string
* Mike's feedback
* fix getIcon hook
A file shared with no text, or a message that only mentions the bot, reached the thread history as an empty user
message, and Anthropic rejects the whole request for it. Name the attachments instead, or leave the message out.
* Warm the Kondo cache so cache-reading hooks run in CI
`hooks/ns-analysis` reads only `.clj-kondo/.cache`, and Kondo writes that cache after every file in a run has
been analysed. `mage kondo` deleted the cache and linted in one pass, so a hook that asks about another
namespace's vars found nothing and skipped its check. `:metabase/prefer-with-dynamic-fn-redefs` therefore never
fired in CI while warning locally against a warm cache.
Lint once and discard the findings before the real pass. `kondo-redundant-ignores` and `kondo-insert-ignores`
get the same warm-up: on a cold cache the former reports needed suppressions as redundant.
Turning the linter on exposes 327 flagged `with-redefs` forms. 316 become `mt/with-dynamic-fn-redefs`. Nine keep
`with-redefs` where the redefined function runs on a thread a dynamic binding does not reach, or on a value the
proxy cannot wrap. Two `.cljc` sites are covered by a namespace entry in config.edn instead, since the macro is
JVM only.
Delegating replacements capture the original with `mt/original-fn`. A bare symbol or `@#'the-var` resolves to
the proxy once a var has been patched, which recurses on the second test in a JVM.
Inline ignore budget 28 to 37, config suppressions 3 to 4.
* Stop counting prefer-with-dynamic-fn-redefs ignores
Genuine cases are common: Quartz workers, pool callbacks, queue delivery workers and values the proxy cannot
wrap all need a root swap. A numeric ceiling adds churn to every PR that meets one. Each ignore still needs a
comment explaining why.
* Harden the Kondo warm-up and keep a primitive fn on with-redefs
`next-page-size` in the BigQuery driver takes and returns primitive longs, so compiled callers invoke it
through `IFn$LLLLL`. The dynamic-redefs proxy is a plain variadic fn, so the conversion threw
ClassCastException, and since patching is permanent every later BigQuery query in the JVM would too.
The warm pass now also lints `dev/src`, which defines vars that `dev/test` redefines. That exposes six sites
in the kondo ratchet tests, now converted. A warm pass that exits with anything but 0, 2 or 3 fails the
task rather than leaving a partial cache. `kondo-updated` warms when no cache exists, and the ratchet tools
warm the roots they lint.
* Only skip kondo-updated's warm pass after a finished one
A cache directory can be partial, left by an editor or an interrupted run, and `kondo-updated` then skipped
warming and missed cache-dependent findings. `warm-cache!` now writes a marker when it finishes, deleting it
first so an interrupted pass can't leave an old one, and `kondo-updated` checks for the marker instead.
* Only let a full warm pass mark the Kondo cache warm
A warm over a narrower set of roots leaves the cache incomplete, so it must not write the marker
`kondo-updated` trusts, nor clear one that a full pass wrote.
* Point the Athena TODO at the macro the test now uses
* Say why kondo-updated accepts a stale cache
Snowplow events are POSTed asynchronously and then ingested by
snowplow-micro before they can be read back via `micro/good`. The
1000ms retry budget in the Cypress helper was too tight for the event
to land on loaded CI runners or under network throttling, producing
spurious "Snowplow retry timeout" failures. Bump the budget to 5000ms;
this only affects how long a failing assertion waits, since matching
events still return early.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Drop the unused metadataFields prop from the reference detail pages
* List the reference databases from the fetched response
* Fetch the reference segment list where it is rendered
* Add relation selectors to the metadata store
* Read the data selector's entities from the store
* Take the data selector's entities as plain objects in its consumers
* Format the data selector spec
* Inline the table schema lookup
* Name the data selector's lookups prop entityLookups
* useRenderedDataVisibility
* static viz, other adjustments
* remove dead hook
* Split panels, simplification
* handling line series with a range very close to chart boundary, ignore goal line
* return true on traversal if intersection is found
* Move security-center endpoints into enterprise security_center
Moves security-center.ts out of the shared metabase/api module into
enterprise/frontend/src/metabase-enterprise/security_center/api.ts. All
of its endpoints hit /api/ee/ URLs and every consumer lives in the EE
security_center module, so it was an OSS api file with zero OSS
consumers.
The file now uses EnterpriseApi.injectEndpoints, matching the existing
in-module EE precedents (monitor/ai-auditing/metabot-analytics/api.ts,
table-editing/api/index.ts). The security-advisory tag type moves from
the OSS TAG_TYPES to ENTERPRISE_TAG_TYPES alongside it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Cast test store state for EnterpriseApi selectors
The listSecurityAdvisories endpoint selector is now typed against the
enterprise tag union, while renderWithProviders builds a store typed
with the OSS State. The runtime state shape is unchanged, so the spec
casts through a helper with a justification comment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use relative imports for EnterpriseApi inside the enterprise module
security_center/api.ts was reaching its own module through the
metabase-enterprise alias; same-module imports are relative per the
import-style convention.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Import EnterpriseApi through the metabase-enterprise alias
../api/api reaches the sibling api module, not security_center's own
files, so the same-module-imports-are-relative convention does not
apply here. The alias form matches the other in-module EE endpoint
owners (metabot-analytics, table-editing) and is the specifier the
no-base-api-access rule tracks EnterpriseApi by.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Rewrap the cast comment in the promo card spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Verify security-center mutations invalidate the advisory cache
* Simplify security-center tests and flush query updates
* Match the security-center api spec titles to the folder's should idiom
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Use affected-test plans to narrow frontend unit tests
* Pass affected test plans by artifact and reject Jest runner failures
* Extract unit test selection preparation from workflow YAML
* Follow the shared Jest config files in the frontend path filters
* Drop the unused import/no-commonjs directive from the filter spec
* Leave heap retention specs out of the planner's unit list
* Check unit test results in a script and let suite hook failures reach quarantine
* Warn when a narrowed unit test run matches none of Jest's test paths
* Reword the workflow cast comment in the test plan spec
* Serve the locale catalogues as rspack chunks
The catalogues were fetched from `app/locales/<locale>.json`, a path outside
everything rspack does for an asset. They were unhashed, so `cacheable?` never
granted them far-future headers, and uncompressed, so the server gzipped each
one per request.
Importing them instead puts them through the normal pipeline: rspack hashes each
catalogue, emits it as its own chunk under `app/dist`, writes the `.br` and `.gz`
siblings, and resolves the URL at runtime. A hashed file there is already
cacheable, so no new rule is needed.
Production swaps `devtool` for SourceMapDevToolPlugin, which takes an exclude. A
source map for a catalogue maps JSON onto itself and would add about 29 MB.
* Format the specs the locale change touched
* Keep the catalogues out of the SDK bundle
A context module bundles its whole directory wherever it resolves, so importing
the catalogues added all 36 to the SDK's reachable graph: 66 files to 102, and
its total gzipped size up 275%.
The SDK should not carry them regardless of size. It renders in a customer's
page against whichever instance it is pointed at, so its catalogue has to come
from that instance rather than from whenever the SDK was built.
`locale-catalog` now names how a catalogue is loaded, the way `ee-plugins` and
`sdk-ee-plugins` name their implementations. The app imports from its own
bundle; the SDK bundle config aliases the fetch instead, which also keeps the
context module out of it.
* Let the SDK cache the catalogues it fetches
A catalogue served from `app/locales/<locale>.json` carries no hash, so
`cacheable?` never matched it and the SDK re-downloaded one on every load.
The SDK does not need the app's chunk hash to fix that, and it cannot have it:
the two bundles are separate compilations with their own chunk ids, filename
templates and runtime wrappers, so the same catalogue hashes differently in
each. What it needs is any token that changes when the catalogues do, and it
already has one. The instance serves the SDK bundle, so whatever build produced
it produced the catalogues too, and `EnvironmentPlugin` already compiles that
build's version and commit into it.
The SDK now names its build in the query, and a catalogue request that carries
one is cacheable. Without one it is not, so a caller that omits it cannot pin a
stale catalogue.
* Move the locale loading out of metabase/api
Nothing here calls an API endpoint. The catalogue is a static asset, loaded by
import in the app and by a plain fetch in the SDK, so it sits alongside
`utils/i18n`, which applies what these return.
* Swap the locale loader by path, not by alias
The loader was reached through a `locale-catalog` alias, which had to be
declared in the rspack aliases, tsconfig and jest config before the SDK bundle
could override it: four places to say one thing.
The importer now uses a plain relative import, which every resolver already
understands, and the SDK config keys its override on the resolved path. One
config knows a swap happens, and it is the one doing the swapping.
* Let the SDK load catalogues as chunks too
The SDK fetched catalogues from the instance over HTTP because a context
import pulled all 36 into its bundle. They were never in its initial
payload: an import() context always emits async chunks. What tripped CI
was the gate itself, which is `embedding-sdk-chunked/total`, the reachable
set. All 36 counted, 8.5 MB gzip against a 3.1 MB bundle, so 275%.
The SDK can use the same loader. `sdk-public-path.ts` points
`__webpack_public_path__` at the instance, and the package injects the
bundle as a script tag, so a host app's bundler never sees the import().
Chunks land under `app/embedding-sdk/chunks/`, which the backend already
serves with far-future immutable headers, so the build token and its
`cacheable?` clause are not needed either.
Measure the catalogues out of the reachable totals instead. One loads per
session and none for English, so counting all 36 overstates by 35x, and
their size tracks the translation process rather than any code change.
Drops the loader, its spec, the build-time alias and the backend clause.
The SDK's chunkFilename takes `[name]` so the gate can recognise them.
* Fold the loader into its only caller
Nothing overrides `loadLocaleCatalog` through the build any more, so it does
not need to be its own module.
Drop the SourceMapDevToolPlugin too. It existed to keep source maps off the
locale chunks, which only cost disk in a dev build, and it made `devtool`
conditional to work around the two not being able to run together.
Clear the comments that explained the change rather than the code: the specs
said what the catalogue used to do, and two references pointed at the file
this commit removes.
* Point the locale test hooks at the chunk
The catalogue arrives as a hashed rspack chunk, so the specs that stubbed or
waited on `GET /app/locales/<locale>.json` were watching a request that no
longer happens.
Five e2e specs used the intercept only as a synchronisation point, so they
now wait on the chunk instead. `content-translations` stubbed an empty
catalogue to keep the UI in English while it checks that content is
translated, and a chunk cannot carry a JSON body, so it blocks the chunk. Its
Arabic block asserts English chrome and `AR-` content only, nothing that
needs the catalogue applied.
That leans on LocaleProvider catching a failed load, so cover it: a rejected
catalogue still renders the children.
Also fix the import order the module move disturbed in three consumers.
* Spy on loadLocalization instead of wrapping the module
* Drop a tombstone comment
* Keep the locale set when its catalogue fails to load
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
* Wait on the language landing, not on the catalogue request
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
* Keep the locales directory when the translations step clears it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
* Assert a real Dutch string after the first language switch
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
* Re-render the children once the catalogue is applied
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Consolidate the module viewers into one explorer
`./bin/mage modules-tree --html` writes a single page with three views
of the module config: the tree, a coupling ranking, and a dependency
graph around focused modules. `dev.module-explorer/open!` builds the
same page in a dev REPL with namespace-level edges, so the graph can
show which API namespaces each consumer uses.
The graph replaces dev.module-viz and its Jetty server. Source paths,
stats and teams go through the shared resolver, so nested and renamed
modules report correctly. module-team moves into hooks.common.modules.
* Label module counts as used by and uses
* Refine module explorer semantics and state
* Move MetabotPromptButton into metabot behind a querying plugin slot
* Stop querying reading metabot permissions and sidebar state
* Test the data reference auto-open prop instead of metabot state
* Pass metabot's editor and error UI in as props instead of a plugin slot
* Fix editor specs for the metabot permissions hook and the barrel mock
Only the upload layer's own 4xx errors reach the model, and everything else gets a generic line. CSV validation errors had no status code, so they throw as 422s now and the upload endpoint answers 422 instead of 500 for a malformed file.
* Break requiring-resolve cycles
* Place moved code where its consumers can already reach it
Avoids widening module :api sets for three of the extracted namespaces.
dashboard->resolved-params moves to metabase.parameters.params.
template-tag-parameters and parameter-template-tag? move to metabase.query-processor.card.
Quartz's ConnectionProvider moves to metabase.app-db.quartz; task.bootstrap keeps the connection interceptor.
metabase.app-db.connection, metabase.dashboards.params and metabase.queries.template-tags leave :api.
* Restore comments lost when code moved
The c3p0 deadlock explanation goes back above the `locking` call in prometheus.clj.
The driver-implementation caveats go back on the requires that replaced their requiring-resolve calls.
The PulseChannel-hooks note and the placement rationales for the moved parameter functions come back too.
* Point mcp v2 projections at the moved dashboard->resolved-params
Master's mcp v2 merge landed a caller while the function was moving to metabase.parameters.params.
---------
Co-authored-by: Chris Truter <chris@metabase.com>
A card's `database_id` is copied from its source card's row, so a dependent rewritten before its
source kept the old database id. The cascade now rewrites cards in dependency order, sources first.
* merge containerize-uberjar into uberjar workflow
* big refactor
* move things around, add inputs to fetch-artifact action
* update conditional logic
* clean up comments
cleanup whitespace
* simplify conditional logic
* guard conditional matrix
* review cleanup
* add job names
* cleanup whitespace
* update get-docker-info to resolve repo/tag inputs
* trim trailing whitespace
* add ecr auth if required
* add repo/tag inputs to containerize from uberjar
* use uberjar workflow to push container to ecr
* cleanup pr-env
* add proper label handling to pr-env
* clean up support for ecr
* simplify containerize
* move jar healthcheck into build step
* move containerize back out of uberjar
* review fixes
* a few more fixes
* code review fixes
* cleanup whitespace
* rename workflow to match scheme
* add verification on release, use DOCKERHUB_OWNER var
* fix conditional
* remove whitespace
* end with newline
* add extra guard for labels not pr env related
* Bump tiptap to 3.31.3
* Dedupe prosemirror-transform
* Fix document e2e failures after tiptap bump
* Drop PublicDocument unit spec
* Remove dark-mode band around document title
* Remove the dispatch-type hierarchy
Its only consumer was the `[:dispatch-type/fn :dispatch-type/*]` method on the ClojureScript
`=?-diff`, which the `:default` method now covers with an `fn?` check. Nothing else passed
`:hierarchy #'u/dispatch-type-hierarchy`, so `:dispatch-type/*` was never reachable as a parent
anywhere else.
`dispatch-type-keyword` still returns `:dispatch-type/*` for unmapped types.
* Rename :dispatch-type/* to :dispatch-type/unknown
The `*` read as a wildcard because the hierarchy made it one. With the hierarchy gone it is just
the value `dispatch-type-keyword` returns for a type it cannot classify, and the old name invited
`(defmethod foo :dispatch-type/*)` as a catch-all that would silently match only unclassified values.
* Tighten the dispatch-type docstring and comment
State the contract and drop the rest: the docstring repeated the `;; we should add more mappings here as
needed` comment sitting three lines below it, and said twice that the keywords have no hierarchy.
* Fix flaky persist-refresh admin email test by giving it its own logged-in admin
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Warm babashka classpath once so parallel modules-consistency tests stop racing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Whitelist the babashka classpath warm-up for the parallel-test linter
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: let EditableDashboard ask for the staged data picker
The picker picks itself: staged above 100 data sources, simple below, and
the simple one only offers tables and models. So passing entityTypes:
["question"] to EditableDashboard did nothing on a small instance, with
no way to opt out — SdkQuestion could already ask for the staged picker,
EditableDashboard could not.
Drop dataPickerProps from the shared dashboard props too. Only the
editable dashboard can reach the query builder, so Static and Interactive
had been inheriting a prop that could never do anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ftDCPjFUZxKwoPda82Nc1
* docs: describe dataPicker in the dashboard guide
The guide's data picker paragraph still described dataPickerProps as
entity types only, and the note added for dataPicker called it the "full"
picker, which is not one of the type's values and is a term the guide
never introduces.
Fold both keys into the intro paragraph, name the pickers as the type
does, and leave only the entityTypes constraint after the snippet. The
defaults also go on the dataPickerProps JSDoc, so they reach the props
reference the guide links to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TacHQDkaEDmdu5jYJUPs1X
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records one query_execution row per action execution under new context values action-execute / public-action-execute, so runs appear in the Usage Analytics Query log beside reads.
* Call build-scripts.yml from run-tests.yml
Make build-scripts.yml a workflow_call workflow and invoke it from
run-tests.yml, rather than duplicating its jobs there. The jobs
themselves are unchanged; only the triggers move.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* add proper conditions for build-scripts workflow
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Stop the Data Studio index redirect clobbering a live navigation
`/data-studio` is an index route that only knows where to send you once the
`hasSeenGuide` user key-value request lands. The layout renders the nav around
it in the meantime, so a click can start a navigation while the redirect is
still deciding. Because a `route.lazy` destination resolves its module before
the router commits the location, the index route is still mounted in that
window -- and its `<Navigate>` replaced the navigation the user had asked for,
dropping them on the guide instead.
`useIsNavigating` already exists for exactly this window (see
`useDashboardUrlQuery`), so sit it out: whatever is under way unmounts us.
That is what quarantined the transforms permissions e2e test (GDGT-3169): it
clicked "Data transformation" straight after visiting `/data-studio` and then
looked for the enable-transforms page on the guide. The e2e helper now lands on
the redirect target before touching the nav, which also covers the same latent
race in the schema-viewer entry-point test.
* Trim comments to what a reader needs
* Let the Data Studio index redirect settle in the library entry test
* Cleanup
* GDGT-3169: Address PR comments
* Add store selectors for a table's field ids and foreign keys
* Read the query builder's source table from the store
* Read TableInfo from the store
* Link a connected table with the table URL helpers
* Build the connected table link from a dataset query
* refactor: rename the embedding hub checklist to setup guide (#79514)
* refactor: rename the embedding hub checklist to setup guide
"Embedding hub" originally meant the whole embedding section; what
shipped under that name in Sept 2025 was only the onboarding checklist,
which users already see as "Setup guide" -- it is the nav label and the
route. Renaming the code to match frees the name for the section the
embedding settings reorganization is building, and stops "embedding hub"
meaning two things.
Directory: frontend/src/metabase/embedding/embedding-hub -> setup-guide,
and the admin wrapper alongside it. Symbols: EmbeddingHub -> SetupGuide,
EmbeddingHubXrayPickerModal -> SetupGuideXrayPickerModal,
EmbeddingHubAdminSettingsPage -> SetupGuideAdminSettingsPage.
Deliberately unrenamed: useGetEmbeddingHubSteps, EmbeddingHubStepId and
the rest of the checklist payload types. Those name the
/api/ee/embedding-hub/checklist contract, and the backend keeps that name
on purpose -- renaming three persisted booleans buys nothing user-visible
and costs a migration.
Frontend-only. No persisted state, no API contract, no route change.
* refactor: finish the setup guide rename in tests and backend
EMB-2208 also covers the spec file names and the two backend comments.
The e2e spec and the SDK embed-setup unit spec move to setup-guide names
and their describe wording follows.
The backend keeps embedding-hub in the setting names, the API path and
the namespace, so both files get a note saying the names track the
Setup guide checklist rather than the embedding hub section.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE
* refactor: rename setup guide UI types and hooks
The UI model for the checklist still carried the old word:
EmbeddingHubStep, EmbeddingHubStepId, EmbeddingHubAction,
EmbeddingHubModalToTrigger, the two step hooks, and the home page
component. None of these name the API contract. They are declared in
setup-guide/types, independent of api/embedding-hub.ts, and
EmbeddingHubStepId lists seven step ids where the API's own
CheckListApiStep lists twelve.
api/embedding-hub.ts and everything in it keeps the old name, as does
the embedding-hub-checklist cache tag: those do name
/api/ee/embedding-hub/checklist.
Comments follow. The two above the embedding-hub-*-embed-snippet-created
settings now say the setting names stay while the thing they track is
the Setup guide.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE
* refactor: reword two missed setup guide mentions
An it() title in the SDK embed-setup spec and a comment above the
checklist keys in the API both still said "embedding hub" where they
meant the Setup guide.
The docstrings that name the API itself are left alone, as is the
:embedding-hub token feature.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE
* refactor: rename the checklist API client to setup guide
The client module named the endpoint rather than the contract. Nothing
in it is frozen except the URL, which is a literal string: the RTK cache
tag lives only in the frontend store, and the exported types and hook are
ours. So api/embedding-hub.ts becomes api/setup-guide.ts, its types and
query hook follow, and the embedding-hub-checklist tag becomes
setup-guide-checklist.
The surviving url: "/api/ee/embedding-hub/checklist" gets a comment
saying it is the one thing that has to keep the name.
The backend is untouched apart from comments. Its namespace keeps the old
name, so the symbols inside it stay consistent with it -- the seam is
between the two modules, not inside either.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE
* docs: name the setup guide in two comments
Shortening these to "the checklist" dropped which checklist it is, to buy
consistency with the surrounding names. Losing the information is the
worse trade.
In collection.ts there is no longer a mismatch to buy off anyway, since
the tag beside it is now setup-guide-checklist. In api.clj the comment
does disagree with its own namespace, and that is fine: the namespace
docstring already explains the seam.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE
* refactor: rename the checklist schema and builder
Both are private to the namespace and referenced nowhere else, so
nothing about them is frozen. The frontend type for the same payload is
already SetupGuideChecklistResponse, and two names for one response
across the seam is worse than a symbol disagreeing with the namespace
around it -- that disagreement is explained by the ns docstring.
The namespace, the route, the settings and the token feature keep the
old name, as does the endpoint docstring, which is published in the API
docs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE
* refactor: say Setup guide in the two setting descriptions
They said "for tracking in the embedding hub" directly under a comment
saying these track the Setup guide and not the hub section, so the file
contradicted itself and a reader could not tell which line was stale.
Nothing renders these descriptions -- no admin settings widget reads
them, only the settings API payload carries them -- so they are internal
documentation, and the same accuracy argument applies as to any comment.
The setting names still stay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE
* refactor: say Setup guide in the endpoint docstring
It described the response as the embedding hub checklist while the schema
right above it is SetupGuideChecklistResponse. The path in the route
docstring and the namespace still name the hub, because those are bound
to the URL.
This string is published in resources/openapi/openapi.json and
docs/api.json. Add the openapi-self-healing label to the PR and CI
regenerates the spec; docs/api.json has no per-PR check and picks it up
on its next regeneration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE
* refactor: lowercase setup guide in prose
The codebase capitalises "Setup guide" only where it is the literal UI
label, as in EmbeddingNav, and writes it lowercase in running prose --
"Go to setup guide", "Return to the setup guide to continue". The
backend comments and descriptions added here did the opposite, and
api.clj disagreed with itself between one docstring and the next.
Sentence-initial mentions keep their capital, since that is ordinary
sentence case rather than the name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* style: format the setup guide route imports
oxfmt --check failed fe-lint on the integration branch; the two
setup-guide lazy imports were wrapped in the pre-oxfmt style.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGNfTR7qNNvEQczJQDomoi
* feat: add the embedding hub shell and its Get started tab (#80346)
* feat: add the embedding hub shell and its Get started tab
The hub is a top-level section at /embedding, built on AreaLayout like
Monitor and Data Studio rather than on the admin sidebar, with its own nav
and an app-switcher entry. /admin/embedding stays exactly as it is; removing
it is EMB-1526 and comes last.
Get started replaces the vertical stepper with a grid of numbered cards in
two sections plus a useful-links row. Each Fine-tune step is locked by the
feature it needs rather than by one stand-in for "Pro", since an instance can
license SSO without modular embedding. A feature-locked card names no
prerequisite: "Set up SSO to unlock" is a promise the step is one action
away, which is only true above the paywall.
One tab today. The remaining six arrive with their own issues, each adding a
route, an export and a nav entry.
Two shared changes ride in front of it, each its own concern:
* `BaseLink` resolves an explicit `..` against the current route, which the
hub needs because it mounts admin components under a second base path.
* `AreaContent` grows a `fullWidth` prop, mirroring what the admin settings
layout already does, for the tabs that are editors rather than settings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHCSv6oQRLUUGo34V1eEDp
* docs: correct the embedding hub module tier note
The comment said the hub could drop to feature tier once its components
were lifted out of admin. It cannot: the Permissions and Tenancy tabs
mount admin permissions and admin People a second time on purpose, and
admin keeps its own copy of both, so the hub imports admin-tier code
permanently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE
* fix: finish the setup guide rename in the Get started page
The rename landed on the base branch, so it never reached files that
only exist here: the Get started spec still imported EmbeddingHubChecklist
from metabase/api/embedding-hub, which no longer resolves, and renaming
useGetEmbeddingHubSteps moved it past useSetupGuideModals alphabetically
and broke the sorted import blocks.
Both edits were made during a rebase, where pre-commit hooks do not run,
which is why lint-staged did not catch either one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE
* feat: let AreaTab render an action as well as a link
The embedding hub's "New embed" control opens a modal rather than
navigating, so it was a Mantine Button with its own height and padding
and did not collapse with the navbar. It now goes through AreaTab, which
makes it the same size as every other rail item by construction.
Omitting `to` renders an UnstyledButton instead of a link. The two forms
are spelled out separately because their polymorphic component types do
not unify.
* refactor: make the UpsellBanner body text optional
The embedding hub's banner is a title and a button with no body copy.
Rendering an empty Text left a stray paragraph, and the large variant
top-aligned the row, so the gem and the button sat above the single
title line.
Without children the body is skipped and the row centres. Every existing
caller passes children, so none of them change.
* fix: return to the setup guide the user actually came from
The guide has three hosts, and every route out of it was hardcoded to
the admin one, so a user who started in the embedding hub was deposited
in admin settings on the way back.
The wizards now navigate relatively, resolving to whichever host mounted
them. The connect-a-database round trip leaves the app entirely, so it
carries the origin instead: the return param holds the path rather than
a bare "true", and the modal navigates there. Links already in flight
carry the old literal and fall back to admin as before.
The home-page stepper returns home; the admin guide keeps the default.
* feat: serve the setup guide checklist without a licence, and add the hub's two steps
The checklist sat behind the :embedding premium gate while the hub that
renders it is admin-only, so an unlicensed admin reached the page and
got a 402. Every step then read as incomplete -- including ones the
instance had genuinely done, which is worse than showing nothing. The
gate is gone; the endpoint checks superuser itself, which the gate had
been providing as a side effect.
The hub's own two steps join the response. Completion belongs to the
instance rather than to whichever host is asking, so both hosts read the
same source: configure-ai moves off a pair of frontend settings reads,
and create-custom-theme becomes answerable at all.
Telling a seeded theme from a user's needs a marker: the names are
stored already translated, and seeding is lazy so the ids are not
ordered. Existing rows are deliberately left false rather than
backfilled -- themes are recent, so the instances affected are few and
already embedding-active, and no retroactive rule is reliable.
* refactor: give the hub its own path, step list and unlicensed layout
Get started was the index route, so /embedding was a prefix of every
future tab's path and the wizards were siblings rather than children.
Both forced special cases in tab selection. It now lives at
/embedding/get-started with the wizards nested under it and /embedding
redirecting there, so tab selection is a plain segment match.
The hub also owns its steps outright rather than borrowing actions from
the shared hook. It already owned the copy, and the half-and-half
arrangement is what forced the action lookup and the omitted-step
handling; both are gone, and so is the URL parameter the hook only had
so the hub could pass its own wizard paths.
Without modular embedding every Fine-tune step is locked and AI is the
one advanced step still reachable, so it moves up into the first section
and the grid fixes two columns. Numbering follows position in the list
rather than arithmetic across three groups, which is what let the order
vary at all.
Also drop the helpers, the fullWidth chain and the SetupGuide prop this
branch introduced but nothing calls yet -- each arrives with the tab that
first needs it -- and order each file from its entry point down.
* refactor: address review findings on the hub shell
Restore the two setup-guide e2e tests the branch had deleted; nothing in
it justified their removal.
Drop the AvailableInEmbedding guard from the hub and Data Studio: it sat
inside a guard built from a selector that already returns false in an
iframe, so no state rendered differently. Add the hub selector spec its
Data Studio and Monitor siblings already had.
Take getIsEmbeddingHubApp as a tenth input selector rather than repeating
the path check inline; the instantiation-depth limit that comment warned
about no longer applies.
Order BaseLink's relative check last so the change is a pure append, and
say what actually goes wrong rather than describing it abstractly.
* fix: keep the checklist from 400ing without a database
has-user-added-database? ended in a when-let, so an instance with
neither a user database nor a sample database got nil rather than
false. The response schema is closed and requires a boolean, so the
whole checklist 400d. Every existing test called (mt/id) first, which
creates a database and hid it.
has-configured-ai? gets the same treatment: it ands two settings, and
a nil from either would fail the schema the same way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzD6eCBAWFddH31oQg9woe
* test: cover unlicensed checklist access and the hub's two steps
The 402-without-:embedding assertion described the gate this branch
removed, so it was red. It is replaced by the behaviour that gate
change was for: a 200 with real completion on an unlicensed instance,
paid steps reporting false rather than erroring, and non-admins
rejected now that check-superuser carries the access control the
premium gate used to provide as a side effect.
Adds coverage for create-custom-theme (a seeded theme does not count)
and configure-ai (credentials and embedded Metabot, both halves).
Leaves data-isolation-strategy-test without an (mt/id) call - that is
what caught the nil add-data, so it is the regression guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzD6eCBAWFddH31oQg9woe
* test: add e2e coverage for the hub's Get started tab
The tab shipped on unit tests alone. These five cover what needs a
real browser or a real backend: the redirect from /embedding, both
step orderings, the wizard's relative back link, feature locking, and
completion arriving from the checklist endpoint unlicensed.
Its own filename rather than sharing the security tab's spec - the two
cover different surfaces, and separate files mean no conflict when the
stack restacks.
Both blocks are @EE. The unlicensed case is an EE build with no token,
not an OSS build: the checklist is served from /api/ee, so on OSS the
route would not exist and every step would read incomplete for a
reason this page is not responsible for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzD6eCBAWFddH31oQg9woe
* chore: record the embedding-hub module's new dependencies
Regenerated by ./bin/mage fix-modules-config. Both entries come from
the two checklist steps this branch adds: metabot for the AI settings
has-configured-ai? reads, and :model/EmbeddingTheme for the is_default
lookup behind has-created-custom-theme?.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzD6eCBAWFddH31oQg9woe
* fix: account for embedding_theme.is_default in the serdes spec
serialization-complete-spec-test requires every column on a serialized
model to be declared, and is_default was new. It goes in :skip rather
than :copy: the flag records whether this instance seeded the row, not
anything about the theme itself, so it does not travel. An imported
theme lands as false - the admin's own - which is what importing one
is, and it ticks the create-custom-theme checklist step accordingly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzD6eCBAWFddH31oQg9woe
* refactor: serve the setup guide checklist from OSS
A community build never mounts /api/ee/*, so the checklist endpoint did
not exist there at all -- the four steps its UI actually shows without a
licence (connect a database, create a dashboard, get an embed snippet,
configure AI) had no backend to report them.
Move the namespace to metabase.embedding-hub.api and the route to
/api/embedding-hub/checklist. Two things had to change to load without
enterprise code:
- The JWT and SAML settings ship only with enterprise code, so a missing
require would fail at load. has-configured-sso? becomes a defenterprise
whose OSS body returns false; the real implementation stays in
metabase-enterprise.embedding-hub.api behind :feature :embedding, the
same token the route was gated on before.
- :model/Tenant, :model/ConnectionImpersonation and :model/DatabaseRouter
resolve to enterprise namespaces a community build does not ship.
Guarding each query with the :embedding feature short-circuits before
Toucan resolves the model, the way has-configured-sandboxes? already
did with :sandboxes.
Every key stays in the response and keeps its type, so the frontend needs
only the new URL. Enterprise behaviour is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5HK8wzZwmAsF9PkgFMfLu
* docs: spell out "embedding hub" in setup guide comments
A bare "hub" reads ambiguously next to the setup guide, which the same
files also talk about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5HK8wzZwmAsF9PkgFMfLu
* refactor: keep the moved checklist code closer to its original
The move added a few things it did not need to: boolean wraps around
expressions the :type :boolean settings already returned as booleans, a
nested cond where a flat one had been, and a named helper where the file
already inlines its feature checks. Undo all of it, so the diff against
the original shows only what moving the namespace forced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5HK8wzZwmAsF9PkgFMfLu
* refactor: address review feedback on the embedding hub
Frontend: object args for useSetupGuideModals, {isLocked, reason} for
locked steps, edition-aware embed snippet copy, no complete badge on
locked cards, margins instead of nested stacks, and trimmed comments.
Backend: gate enterprise-only models on ee-available? rather than the
:embedding feature, and move the checklist api test to OSS.
* refactor: keep the original order in useGetCurrentApp
The hub lives at /embedding, so it never overlaps /admin -- moving the
admin branch to the end changed nothing at runtime and only widened the
diff. Append the hub case instead.
* refactor: rename the upsell utm constants
Renames UPSELL_CAMPAIGN/UPSELL_LOCATION to UTM_CAMPAIGN/UTM_CONTENT and
points SetupGuide at the "setup-guide" campaign.
Also restores the `boolean` wrapper on has-user-added-database?, since the
trailing when-let yields nil on an instance with no sample database and the
checklist response schema requires a boolean.
* fix: unbreak the embedding hub module boundary
The OSS embedding-hub api namespace now requires metabase.config.core, so
the module needs `config` in its :uses set — regenerated with
`./bin/mage fix-modules-config`.
Also reformats admin/routes.tsx to satisfy oxfmt.
* test: drop unit tests the e2e spec already covers
The Get started page's e2e spec walks the real step order, the upsell banner
and a checklist round-trip against a live backend, so the unit tests asserting
the same things were restating a weaker version of it. What is left is what
e2e cannot reach: mixed token shapes, checklist states that need a mocked
response, and the locked-card click gate.
routes.unit.spec.tsx stubbed the guard, the layout, the page and the whole
setup-guide module, which left it asserting that React Router matches a path.
Rewritten to read the route tree as data instead -- no mocks, no rendering,
and it still covers sso-setup, which no e2e visits.
Also drops two tests that asserted a code path the page does not have: it
builds its Fine-tune steps from a local list and never calls
useGetSetupGuideSteps, so no card can be omitted.
* test: drop the hub root redirect test
One line of route config that the Get started e2e spec already asserts
against a real browser. Removing it takes the Navigate lookup, the type cast
and the ReactNode plumbing with it, leaving the path list -- which is the part
nothing else covers.
* refactor: give the New embed action its own nav component
New embed is not a tab -- it renders in the nav's lower slot, outside the
tab list, is never selected and navigates nowhere. Reusing AreaTab for it
meant making `to` optional and branching the render, which put a link/button
split into a component seven other tabs depend on.
AreaNavButton is the button form as its own sibling, so AreaTab goes back
untouched.
* test: name only what the AI checklist test arranges
The block sets the provider key and nothing else -- embedded Metabot being
on is the default, not part of the arrangement. The block below it is what
pins that flag's contribution.
* style: match the New embed button to the design
It carries its own fill at rest rather than lighting up only on hover the
way a tab does, and its label and icon take the brand colour. Centred in
both the expanded and collapsed states.
* style: build the New embed button on the design system Button
The Figma spec is the design system Button -- subtle variant, MD, an `add`
leftSection -- so use it rather than hand-rolling the chrome. The tinted
resting fill comes from Mantine's `light` variant; our own `subtle` renders
transparent, which is a divergence from the Figma token worth raising with
whoever owns the design system.
* test: drop the AreaNavButton spec
It pinned that the component renders a button rather than a link, which was
a real decision while the chrome was hand-rolled. Building on the design
system Button makes it structurally true, so the spec now covers Mantine.
The layout spec already covers the only branch left, the collapsed label.
* test: sharpen the Get started page assertions
The per-feature test framed licensing one feature as a customer shape; it is
only how the gating is shown, since Pro carries everything bar the cloud-only
features. The prerequisite comment now says why an unlicensed step names none:
it is locked regardless. The AI modal is asserted by its title rather than by
there being any dialog at all.
* test: walk both setup wizards to the end
Each wizard now has a test that seeds its steps, reaches the last one and
takes the action that closes it out, asserting the hub is what it lands on.
They end differently: SSO navigates through the anchor itself, permissions
through a navigate("..") handler.
Also corrects the header comment. Both blocks are @EE because the steps are
enterprise-only, not because of where the checklist endpoint used to live --
it moved to OSS, so the old reason no longer holds.
* feat: name the area in the sidenav
The design gives the embedding hub a title beside its logo. AreaLayout
had no notion of one, so it takes an optional title and passes it
through; Monitor and Data Studio are unchanged.
It hides with the navbar, like the header controls do, so a collapsed
rail stays icon-only.
* docs: promise only the basics on the OSS first-embed blurb
AI is not part of a simple embed on OSS, so the pro line oversells what
the steps below it cover.
* perf: load the embedding hub in its own chunk
The hub pages were reachable from the eagerly imported route tree, so
they shipped in the main bundle for every user, embedding or not. Name
each page in an import() and register it with lazy, as monitor and
transforms routes do.
Resolve every loader in the route spec: an import() path and its export
are no longer type-checked, and a typo would first show as a blank page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH
* test: name the hub spec for the hub, not one tab
Every tab's browser test belongs in one spec, one describe each, so the
file name cannot go stale as tabs land. Its header pointed at a path that
does not exist; the older onboarding checklist is in
onboarding/embedding-homepage.cy.spec.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH
* fix: import the user selectors from metabase/current-user
getUserIsAdmin and useUserKeyValue moved into the current-user module
on master, so the old paths no longer resolve and the frontend build
fails outright.
* test: mock the LLM endpoints the AI card opens
The AI card opens AIProviderConfigurationModal, which fetches
/api/llm/providers and /api/llm/provider-types. Neither was mocked, so
"opens the provider modal while unconfigured" failed on the unmocked-route
guard -- deterministically, on every branch carrying this spec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* style: sort the LLM mock imports
sort-imports orders members by codepoint, so ProviderTypes precedes
Providers. Caught by fe-lint, not by the pre-commit hook.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* fix: restore connect-database card copy on the get started page
The card had inherited the "Create a dashboard" card's description
from a copy-paste. Restore the wording used by the old setup guide
checklist while the design catches up.
* refactor: replace NumberedCard render closures with a HubCard union
The section arrays only needed to carry which card to render and its
data; wrapping each in a (position) => ReactNode closure just to defer
numbering added a layer with no benefit over switching on a plain
union at render time.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: add the embedding hub's Security tab (#80347)
* feat: add the embedding hub's Security tab
The method toggles become rows in one card rather than a card each, in the
design's order: Modular embedding, Modular embedding SDK, Guest embeds,
Full-app embedding. Below the paywall the card lists guest embeds alone --
the others need `embedding_simple`, whose runtime only registers when the
token carries it, so their toggles would be switches that turn nothing on.
The card's heading appears only when it lists more than one method.
SameSite is paid-only for the same reason: it governs the session cookie and
guest embeds never set one, so on a guest-only instance it offers a decision
that cannot affect anything the admin owns.
Published guest embeds are listed when any exist, whatever the toggle says.
That needs the two listing endpoints to answer with embedding switched off,
so they drop `check-embedding-enabled` and keep only their settings
permission check. Publishing and every serving path stay gated -- an admin
who has just turned guest embeds off is exactly the one who needs to audit
what is still live.
Admin's own Security page and its siblings keep their current exports
alongside the new ones; EMB-1526 removes that section and them with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHCSv6oQRLUUGo34V1eEDp
* fix: keep the CORS description literal for i18n extraction
babel-plugin-ttag extracts translatable strings at build time, so a jt
template's static text has to be literal in the source. Hoisting the
sentence into a variable and interpolating it made the extractor
hard-fail, which broke verify-i18n-files on this branch and on every
branch stacked above it.
Branch the whole jt expression instead, so each arm carries its own
literal sentence and the HoverCard stays the only interpolation. The
edition-aware copy is unchanged, and the resulting msgid matches what the
base branch already emits, so existing translations still apply.
* refactor: use the shared UpsellBanner on the Security page
Follows the Get started page off the hub's fork of UpsellBanner and onto
the shared component, wired the same way the embedding settings page
already wires it.
* test: scroll the published guest embeds card into view
The hub clips its content and scrolls it internally, so on a CI-sized
viewport the card sits below the fold. Cypress treats an element clipped
by a non-scrollable `overflow: hidden` ancestor as hidden, so findByText
resolved the heading but `be.visible` failed.
Both assertions were identical, so they move into one helper.
* feat: present modular, SDK and guest embeds as one switch
Separate switches add complexity without adding security, since whoever
can embed also controls them, and guest is a property of modular rather
than a method beside it. Full-app keeps its own switch.
The merge is presentational: the switch reads on when any of the three
settings is on and writes all three at once, which is what the backend
flag will do once EMB-2257 replaces them with one. A setting pinned to an
env var cannot be written, so one pinned setting locks the row rather
than letting a partial write recreate the mixed state.
* test: cover the Security page's conditional cards
The spec only exercised the published-embeds card. SameSite and the full-app
origins list are conditional too, and their conditions are the ones a reader
is most likely to get wrong.
* feat: rewrite the embedding legalese modal copy
One switch now covers modular embedding, the SDK and guest embeds, so
the modal no longer needs a per-setting headline. The list becomes one
paragraph, and the actions read Cancel / Agree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu
* feat: name the method each Security setting belongs to
One page now holds settings from several embedding methods, so the
secret key and the authorized origins say which method they apply to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu
* feat: reword the SameSite cookie description
Drop the method list, split the copy into two paragraphs, and render it
in the secondary colour the other rows inherit from SettingHeader --
the nested Text was resetting the colour to primary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu
* feat: reword the embedding methods rows
The paid row leads with what modular embedding and the SDK let you
build. OSS has one method, so its row says Enable embedding rather
than naming guest embeds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu
* test: match the renamed authorized origins title
The widget title now names the method, so the exact-text query no
longer matched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu
* docs: source the merged switch rationale
The reason was real but read as an assertion. Name where it came from:
Alessio's call in the Embedding Settings Reorganization thread, carried
by EMB-2257.
Trim the OSS paragraph to its first sentence. Which methods the token
feature gates is visible in the code right below it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH
* perf: load the hub's Security tab in its own chunk
Follows the lazy route registration the Get started tab now uses, so the
Security page does not pull the admin settings widgets into the main
bundle. Resolve every loader in the spec: an import() path and its export
are no longer type-checked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH
* refactor: drop the EmbeddingSecurityWidgets wrapper
It had one consumer. The admin Security page never used it -- it renders
the two widgets itself -- so the extraction shared nothing, and living in
that page's file made the two look like one component renamed. The hub
page composes the widgets directly instead.
Restores the admin page's spec, which had been repointed at the wrapper
and so left the shipped page uncovered. The widget behaviour it asserted
is already covered by the hub page's own spec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH
* refactor: compose the methods card the way peer settings do
Pass the heading to SettingsSection instead of rendering a Title inside
it, and drop the Stack the section already renders. Label the row's
switch, which every other embedding toggle does.
Say where the merged-switch decision came from rather than asserting it,
and mark mergedSettingKeys as temporary at both ends -- EMB-2257 replaces
the fan-out with one setting and deletes the prop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH
* refactor: title the published embeds card as its source page does
SettingTitle, matching the admin Guest embeds page this card came from,
which also gives the section its anchor id.
Drops embeddingHubAppearance -- no appearance route exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH
* fix: write every merged setting from the terms modal
The modal intercepts the first enable, and it wrote only the setting it
was handed. So the very first time an admin turned the merged switch on
-- the path every new instance takes, since the terms start unseen --
enable-embedding-sdk and enable-embedding-static stayed off while the
switch read on.
The existing test missed it by pinning show-simple-embed-terms to false,
which routes around the modal. Covered now at that seam, and end to end
through a real click and a fresh load.
The browser test has to start from embedding off, which the default
snapshot does not: it enables sdk and static, and the merged switch reads
on when any of the three is on. Publishing a dashboard is itself gated on
guest embeds, so the sibling test enables them before it publishes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH
* fix: check embedding and embedding_simple features independently
Modular embedding and full-app embedding are separate token
features. Gating full-app on embedding_simple hid it for accounts
that only have the embedding feature.
* test: cover embedding_simple and embedding features independently
The methods card now checks the two token features separately, so
the test setup needs to mock embedding alongside embedding_simple
and cover the full-app-only case the merge previously collapsed.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: consolidate the three embedding method settings into one flag (#80348)
* feat: back the merged embedding switch with one setting
Modular embedding, the SDK for React and guest embeds become one
setting, `enable-embedding-modular`; full-app keeps its own. The
previous commit merged them in the UI only, writing all three at once,
which left the gates reading something the switch did not say.
There is no migration. Env beats the app db when a setting resolves, and
a Liquibase changeset cannot see env, so a migration would mishandle
env-pinned instances -- and writing env-derived values into the db would
freeze them, breaking the operator's env var as the live control.
Instead the getter falls back: until the new setting is set it reads as
the OR of the three it replaces, taken through normal resolution so
their env vars count. Nothing is rewritten, so nothing is lost going
back.
The fallback keys off the raw value's *source*, not its value, because
an unset boolean reads as its `false` default. Without that, "an admin
turned it off" and "nobody set it" are the same state, and turning
embedding off bounces back on from a legacy value.
Analytics use `simple_event` rather than a new `embed_share` version:
its event field is a closed enum, and Snowplow is on its way out in
favour of Metaplow, so versioning it is throwaway work.
The three old settings are deprecated rather than removed. Retiring
their env vars is a breaking change, documented separately.
* chore: mark the replaced embedding settings deprecated in 0.65.0
The embedding hub is expected to ship in 65. Tracked in the Notion
release-projects entry; if it slips, these three move with it.
* fix: report embedding telemetry from the setting that is now written
Nothing writes the three replaced settings after the cutover, so on any
instance using the merged toggle they stay false forever while embedding
is on. Left alone, every field derived from them would report embedding
as disabled and adoption would look like it collapsed.
Both call sites now read the setting that is actually written. The field
names stay as they are so existing reports keep resolving, which does
mean three fields that used to differ now always agree -- there is no
distinction left to report.
internal-stats reads through `setting/get` rather than
`get-value-of-type`, which bypasses custom getters and would have missed
the fallback to the replaced settings. Its output feeds metering-stats,
so this one is a billing signal, not only analytics.
* test: point the embedding tests at the merged setting
The three settings the modular flag replaces still exist, so these kept
passing through the fallback getter rather than through the gate they mean
to exercise.
The migration test keeps the legacy keys: it asserts on rows a historical
changeset wrote, not on current behaviour.
* refactor: read the merged embedding setting everywhere
Frontend and e2e still keyed off the three settings the modular flag replaces.
Repointing them is what makes the flag the single source of truth rather than
one more value alongside them.
The legacy keys stay in the settings types: the backend still defines them.
The legalese modal loses its setting argument. It now writes the merged flag
and clears both terms settings -- either one left on would re-open it. Turning
guest embeds on now goes through the modal, which it did not before; that
follows from guest being folded into modular.
* fix: keep the embedding terms modal paid-only
Consolidation pointed the OSS row at the same setting the modal guards,
so an OSS admin enabling guest embeds would have been asked to accept
terms that do not apply: the copy is about modular embedding and the SDK,
and about shared accounts as unfair use of a paid seat, while guest
embeds serve anonymous viewers over a signed JWT and OSS has no seats.
Gate the terms path on the embedding_simple token feature. Paid still
sees the modal on off -> on, OSS never does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH
* test: start the embed modal card test from embedding off
The merged setting reads as the OR of the settings it replaces, which
the pro snapshot leaves on, so the card the test looks for never
rendered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu
* fix: stop reading the deprecated embedding settings
Marking the three replaced settings deprecated made every remaining
call site a lint failure. some-embedding-enabled? reads the merged
setting, which answers the same until an admin writes it. The env-var
sync tests exercise the deprecated settings on purpose, so they keep
them behind an ignore, and the budget records the ten new ones.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu
* test: drive the guest embed tests from the static flag
These exercise the /api/embed endpoints, which guest embedding gates.
Pointing them at the merged enable-embedding-modular made them assert
against a flag wider than the behaviour under test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH
* fix: collapse the merged embedding flag in the e2e setup
Renaming the three settings to one left each call site written twice.
In embed-parameters the two calls disagreed -- simple true, static false
became modular true then modular false -- so the whole spec ran with
embedding off.
The two auth-switch tests now share one setup by design, since one flag
governs both options. Their titles said otherwise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH
* feat: consolidate the two embedding terms settings into one flag
The merged toggle governs modular embedding and the SDK, so the terms
behind it are one gate, not two. The switch was OR-ing the pair inline
while the wizard and theme editor still wrote only the simple one, which
re-opened the legalese modal over terms the admin had accepted.
show-modular-embed-terms reads as that same OR until an admin answers it,
so an upgrade cannot drop an acceptance, and every accept path now writes
the one setting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH
* docs: explain the merged embedding stats read and e2e setup
Note why the stats namespace reads through setting/get (to run the
fallback getter), and what the merged e2e specs start their embedding
state from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH
* test: give the embedding settings specs one enable option
The helper carried isEmbeddingSdkEnabled and isEmbeddingSimpleEnabled,
OR-ing them into the single setting that now backs the switch. They also
doubled as the embedding_sdk and embedding_simple token features, which
stay separate, so the two uses are split rather than merged: one
isEmbeddingEnabled for the setting, token features passed on their own.
Restores envSettingKeys over a boolean, since the card still houses a
second row whose setting can be pinned to an env var, and drops terms
fixtures from specs that cannot reach the modal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH
* test: cover the merged legalese behaviour
One flag means a state that could not happen before: embedding on with
the terms never accepted. An OSS admin turns guest embeds on, sees no
legalese because none is shown below the paywall, and upgrades.
The admin toggle prompts on off to on only, so it stays quiet there by
design; the embed flow asks for the terms instead. Both halves are
covered, along with the other states the flow section renders.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH
* test: drive the embed setup helper from one enable flag
The helper OR-ed simpleEmbeddingEnabled and guestEmbeddingEnabled into
the single setting the switch now backs -- two options that cannot be set
independently once there is one flag. They collapse into one
modularEmbeddingEnabled, which drives the setting and the token feature.
The two guest AppearanceStep tests kept modular enabled via the simple
option anyway, so dropping the guest option leaves their behaviour (dim
state follows the static terms) unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH
* test: point the embed API tests at the merged flag
check-embedding-enabled now gates on enable-embedding-modular, but this
namespace still set enable-embedding-static at four sites, so the whole
embed API suite 400'd with "Embedding is not enabled." in every driver
job. Its siblings were all repointed; this file was missed.
The merged getter's legacy fallback does not cover it: it only fires
when get-raw-value-source is :default, and that checks the db value
first, so any earlier test writing the merged flag kills the fallback
for the rest of the JVM.
* fix: add modular embedding to the embed_share enum
The merged setter emits modular_embedding_enabled/_disabled, which was
not in the embed_share iglu enum. The comment called that acceptable
because Snowplow is being retired, but public-sharing-embed-flow's
afterEach asserts zero bad rows, so it failed CI.
Adds schema 1-0-3 with the two values, the same way simple_embedding_*
was added in 1-0-2, and bumps the version the tracker sends. 1-0-3 also
drops the stale self.version 1-0-1 inherited from 1-0-2.
* test: add the merged terms setting to the serdes baseline
show-modular-embed-terms is :export? true, so it appears in a fresh
export but not in the checked-in baseline, failing
baseline-comparison-test on every app-db and the EE backend suite.
enable-embedding-modular needs no entry -- it is :export? false.
* fix: track the modular embedding toggle as a simple_event
embed_share's `event` is a closed enum, so modular_embedding_* needed a
new schema version. Cutting 1-0-3 was a worse trade than it looked:
schema->version holds one entry per event type and the four legacy
toggles all fire embed_share, so pointing that entry at an unpublished
1-0-3 makes the URI unresolvable and drops every embed_share event in
prod, not just the modular ones.
simple_event takes a free-form event string, needs no schema at all, and
track-event! already fans out to Metaplow, which is where embedding
analytics are headed anyway. This is what EMB-2257 specified. The context
the enum-typed event carried moves into event_detail, the way the SDK
bundle already sends it.
Reverts the 1-0-3 schema added in a5cc6db545d.
* refactor: name the legalese gate for what it decides
The flag reads as show/do-not-show the legalese, so the toggle guard and
the fallback test labels say that, and the labels spell out that
unaccepted is true and accepted is false.
* test: enable preview embed tests through the merged flag
The local helper turned on enable-embedding-static, which only reaches
the endpoint guard through the legacy fallback. An earlier test in the
namespace leaves enable-embedding-modular explicitly set -- with-temporary-setting-values
captures through the getter and restores through the setter -- so the
fallback is gone by the time these run and they 400.
* chore: budget the vector-less kondo ignores this branch adds
The embedding settings sync tests read deprecated settings on purpose,
and use the vector-less ignore form at ten new call sites. Master's
ratchet schema has no :all entry, so seed one at the actual count.
* test: justify the vector-less kondo ignores in the settings tests
Master's kondo-ratchet-test now requires an explanatory comment on any
ignore whose linter is outside :comment-exempt, and :all is not in that
set. Each of the ten sites says why it reads a deprecated setting.
* test: use the map form for the deprecated-var ignores
The ten ignores in the settings tests were the vector-less
#_:clj-kondo/ignore form, which suppresses every linter on the next
form. Naming the rule means the suppression says what it suppresses.
:all drops out of the ratchets entirely (that budget was all this
branch's) and :deprecated-var goes 86 -> 96. Numbers regenerated on
this branch with ./bin/mage fix-kondo-ratchets --seed :deprecated-var.
* test: accept the merged terms in the get-code spec
show-modular-embed-terms falls back to the legacy sdk and simple
settings until an admin accepts it, so clearing only the simple one
left the terms still owed and the Agree CTA still reachable.
* refactor: drop the replaced embedding settings from the types
enable-embedding-sdk, -simple and -static and the two legacy terms
settings have no frontend reader left now that everything goes through
enable-embedding-modular and show-modular-embed-terms.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: point the merged-in EMB-2309 test at the modular flag
Master's new test used simpleEmbeddingEnabled, which the three-way
embedding flag consolidation renamed to modularEmbeddingEnabled. The
merge took both sides cleanly, so nothing conflicted.
* feat: add the embedding hub's Authentication tab (#79509)
* feat: add the embedding hub's Authentication tab
A JWT-only view of the authentication settings. Admin's authentication page
does not change -- this is a second view onto the same settings, so the admin
JWT form is mounted as-is rather than forked, which brings user provisioning
along with it.
Below it, a banner links out to the other SSO methods. After the admin
embedding section goes, this is the hub's only route to SAML, OIDC, Google
and LDAP, so the link is functional rather than decoration.
Below the paywall the tab shows an upsell. `UpsellSSO` could not be reused:
it is the narrow sidebar card from admin authentication and hardcodes a
242px max width, so it renders squished as a page-level upsell. The new one
follows the shape the shipped embedding upsells already use, which is also
why `UpsellCardContent` gains an `image-panel` variant -- a single bordered,
tinted panel, for artwork that carries its own framing or none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHCSv6oQRLUUGo34V1eEDp
* fix: reword the upsell trial copy
Design's wording for the shared upsell card: "Get a 14 day trial of this
and other Pro features." The theme upsell spec asserted the old string,
so its trial-copy test could not pass and its negative assertion had
quietly stopped matching anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* feat: give the Authentication tab its three states
The tab showed the JWT form unconditionally. Per the design and
Alessio's answers it now has three: the form once JWT is configured, a
card pointing at admin when only SAML is, and an empty state offering
to set JWT up when neither is.
Only JWT and SAML count as configured SSO, matching has-configured-sso?
on the backend. They are the only methods an embed can drive --
/auth/sso resolves to one or the other, and the SDK ships a jwt and a
saml auth path and nothing else. OIDC, Google and LDAP sign people into
Metabase itself and stay linked out.
Configure JWT goes to the guide's SSO wizard rather than dropping the
admin into a bare form: it is the flow Get started already uses and it
covers the endpoint and the test too. It is mounted under this tab, so
the wizard's back link names Authentication and finishing it returns
here.
The page also waits on the settings query the JWT form gates on, so the
banner no longer renders beside the form's spinner, and the banner is
dropped from the SAML state where the card already links to the same
page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* refactor: show the plain JWT form when nothing is configured
The tab offered an empty state that sent the admin into the setup
guide's stepped SSO wizard, mounted under this tab. Alessio's call:
show the standard JWT page here and leave the stepped flow to the setup
guide, which already owns it -- he is also redesigning the JWT page, so
a bespoke entry point here would be short-lived.
The SAML-only state is unchanged: it still shows its card and its link
to admin, because JWT cannot be configured from this tab in that case.
Removes the empty-state card, the nested authentication/sso-setup route,
its URL helper, and the back-link label prop that only existed to name
this tab from inside the wizard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* fix: drop the JWT form's own heading in the hub
The hub's Authentication page already renders a page title, and mounting
the admin JWT form inside it added a second one -- two h1s stacked, the
lower reading just "JWT", which the design does not have.
The form now takes an optional `title`, defaulting to the same "JWT" it
always used, so the admin route at settingsRoutes.tsx renders unchanged.
The hub passes `null`; SettingsPageWrapper already skips the heading box
when the title is falsy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* refactor: give the hub its own upsells module
data-studio and monitor each keep their upsell pages in an `upsells/`
directory with a BaseUpsellPage that assembles DottedBackground,
LineDecorator and UpsellCardContent. The hub had no such module, so its
SSO upsell hand-assembled the same four imports from admin/upsells.
Mirrors that structure: BaseUpsellPage holds the assembly, and
AuthenticationUpsellPage is left with the token gate and the copy. The
card is aligned to the page heading the hub renders above it, where the
other two centre theirs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* refactor: trim the authentication page's comments
Cuts the JWT/SAML rationale down to the fact that matters -- embedding
supports only those two -- and the loading note to why a second
subscriber is not a second request.
Drops `?? false` on the `jwt-configured` read. It is optional in
metabase-types/api/settings.ts where its SAML peer is not, but the only
use site treats undefined and false alike, and nowhere else in the
codebase coalesces it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* refactor: drop the image-panel upsell variant
The variant existed so the SSO illustration could sit in a single
bordered panel rather than the nested double frame `image-card` draws.
That is a change to a component every upsell in the product renders, for
one card, so it waits until design confirms the frame is wrong.
The SSO upsell falls back to `image-card` in the meantime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* chore: drop the unused embeddingHubAppearance url
Nothing calls it and `/embedding/appearance` has no route. It belongs to
the Appearance tab, which lands with EMB-1532.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* refactor: stop parameterising the SSO upsell's source
One call site, one value. The data-studio upsell pages hardcode their
campaign and location the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* test: cover the Authentication tab end to end
Three cases the unit tests cannot reach: that the mounted admin JWT form
actually writes its settings from the hub, that a SAML-only instance gets
the Admin card instead, and that an unlicensed one gets the upsell with
nothing configurable under it.
The heading assertion is here rather than in a unit test because the
duplicate title only shows once the real form renders inside the page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* test: tighten the Authentication tab's e2e assertions
Matches the JWT field by its full label rather than a loose regex. The
asterisk is part of it: Mantine renders the required marker inside the
label element, and getByLabelText matches the label's textContent, which
includes aria-hidden nodes.
Also asserts the upsell's own copy on an unlicensed instance, rather than
only that the form is absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ
* fix: lazy-load the JWT form in the hub through the route slot
#80268 (merged from master) moved SettingsSAMLForm/JWTForm/OIDCForm from
eager PLUGIN_AUTH_PROVIDERS components to lazy route slots, dropping the
eager JWT assignment this page's direct render relied on. Loading the same
route slot with React.lazy keeps the hub on the auth-jwt chunk #80268 split
off instead of re-adding an eager import.
* fix: add a background wash to the SSO upsell illustration
Matches the illustration background the Localization and Tenants
upsells use, so the four embedding hub upsell tabs read consistently.
* test: trim duplicate copy assertions from the Authentication e2e spec
Card copy and the SAML-configured link's href are already unit-tested
next to the component. What is left is what needs a real browser --
confirming the upsell (or the SAML card) renders in place of the JWT
form.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: enable modular embedding in new param-values test
Master added card-param-values-native-card-without-parameters-test
enabling only enable-embedding-static. This branch consolidated the
three embedding method settings into one flag, so that setting alone
no longer enables the static embed endpoint and the request 400s with
"Embedding is not enabled". Use the file's own
with-embedding-enabled-and-new-secret-key! macro, as every other test
here does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016amK37aKSDk7sgJsT3AVJQ
* fix: carry the design scale renames onto the stack
GDGT-2486 (#80481) renamed the spacing and radius scales in place,
mechanically remapping every call site that already existed on master.
These files were authored on the stack before that landed, so their
tokens are stale -- still-valid names that now resolve to different
sizes.
Spacing xs->xxs, md->lg, lg->xl, xl->xxl; radius sm->xs, in both the
component props and the CSS module custom properties. Spacing sm is
unchanged at 8px.
Verified against the mapping #80481 applied across its own 1339 files.
CI cannot catch this, so it is a deliberate pass rather than a fix for
a failing check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv
* feat: add the embedding hub's Permissions tab (#79510)
* feat: add the embedding hub's Permissions tab
The admin permissions editor, mounted a second time under the hub. Admin
permissions does not change, and the tab shows whatever the admin editor
shows -- five tabs when tenants is on -- rather than a narrowed copy, so
there is no forked component to keep in sync.
Making it mountable elsewhere needs a base path the URL builders can read.
That is a module-level value rather than a React context, because
`getDatabaseFocusPermissionsUrl` and `getGroupFocusPermissionsUrl` are
called from redux selectors and thunks, which have no component to read a
context from. Only one editor renders at a time, and the write happens
during render, so the first child render already builds correct URLs; admin
wraps its own routes too, so the value is restored whenever admin's editor
mounts after the hub's.
Three navigations inside the editor built their target from a literal
/admin/permissions rather than that base path, so picking a collection or
switching between the databases and groups views threw a hub user into
admin. They go through the base path now, like every other permission URL.
The tab carries the hub's page title, which is load-bearing: without it the
editor's tab row starts at the top of the area, where "Permissions help"
sits under the app switcher.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHCSv6oQRLUUGo34V1eEDp
* fix: restore the permissions base path from admin's own routes
The hub's Permissions tab set a module-level base path during render and
relied on its unmount effect alone to put admin's back. Navigating from the
tab to /admin/permissions runs admin's render before that passive cleanup,
so admin's breadcrumb selectors could build hub links on their first pass.
Admin now wraps its permissions routes in `PermissionsBasePath` as well, so
the default is set during admin's own render. That is what the tab's commit
message already claimed.
Also drops the tab's explicit `<Outlet />`: `PermissionsBasePath` defaults
its children to one, and adds the spec the component never had -- the
set-during-render contract is what the admin wrapper rests on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp
* fix: make the embedding hub's Permissions tab full-width
The 800px content cap and AreaContent's own padding were both still
applying, so the permissions editor never reached the design's full
width. Adds a fullWidth escape hatch to AreaContent and wires it
through for the permissions route only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFKUvQLEMmyQ43m5VPtLMa
* fix: make the permissions editor's selection color configurable
Admin's purple (navbar-admin*, protected from whitelabeling) was
hardcoded into AdminTreeNode's selected row and EntityNameLink,
leaking into the embedding hub's blue-branded mount of the same
editor. Both now read from a context, defaulting to admin's purple;
the hub overrides it via PermissionsBasePath's new selectionColors
prop.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFKUvQLEMmyQ43m5VPtLMa
* fix: derive every permissions accent color from one value
Three separate tokens (background_surface-brand-strong/-subtle, plus
navbar-admin's own triplet) made the override fragile, and missed the
"You've made changes" bar entirely -- it stayed on accent7 regardless.
Collapses the context to a single ColorName; every consumer (tree
selection, entity link, edit bar) derives its own shade from it via
color()/alpha() instead of picking a second unrelated token. EditBar's
admin boolean becomes an accentColor prop so permissions can plug into
the same lever.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFKUvQLEMmyQ43m5VPtLMa
* fix: move the permissions accent color to CSS Modules, off fabricated tokens
The prior fix introduced custom CSS variables (--permissions-tree-node-*)
that read as new design-system colors, and extended two emotion files
that are deprecated in favor of CSS Modules. Replaces both:
- The context now carries a plain isHub boolean (utils/is-hub.tsx)
instead of a color value, mirroring base-path.ts's own module-singleton
shape where that pattern is actually needed (redux selectors/thunks
with no component tree) -- isHub's consumers are all components, so it
stays a React context.
- AdminTreeNode moves out of FilterableTree.styled.tsx into a plain
component + AdminTreeNode.module.css, selecting between colors the
design system already owns (navbar-admin/accent7 vs core-brand/brand)
rather than a fabricated bridge variable. Selectors use `li.<class>` --
a bare class ties in specificity with TreeNodeRoot's own emotion rule
and the winner would depend on injection order.
- EntityNameLink and EditBar keep their original, byte-identical emotion
styling for admin; the hub override is a plain inline style / an
existing `admin` boolean, never a new token.
- EditBar itself is migrated off emotion entirely (EditBar.module.css),
since it was being touched anyway and the pattern was already
deprecated.
- EmbeddingHubLayout's full-width routing moves from an inline pathname
check into a `fullWidth` field on each tab's config, so adding another
full-width tab is a one-line change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFKUvQLEMmyQ43m5VPtLMa
* test: cover navigation through every level of the hub's permissions editor
Clicks through Data (Groups and Databases views), Collections, and
Application, drilling one level into each, then asserts the URL and nav
never leave the embedding hub.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFKUvQLEMmyQ43m5VPtLMa
* refactor: rename permissions is-hub to isEmbeddingHub and rework tree colors
Full name makes the flag's purpose explicit at call sites. Selection
colors move off ad-hoc emotion overrides onto AdminTreeNode.module.css,
using the data-css-specificity-hack convention and core-white instead
of mantine's color token.
* fix: route tenant collection navigation through the hub's base path
Both pages hardcoded navigate("/admin/permissions/...") in their
item-select handler, so selecting a shared or tenant collection while
inside the embedding hub kicked the user out into full admin. Use
getPermissionsBasePath() like every other permissions sub-page, and
restore the e2e test's item-click coverage for both tabs.
* test: assert permissions editor path by prefix, not substring
cy.url().should("include", ...) matches anywhere in the URL. Anchor on
pathname with a leading-^ regex instead, which also makes the paired
not.include("/admin/permissions") checks redundant -- an anchored
/embedding/permissions match already rules that out.
* refactor: simplify permissions color override and drop redundant comment
Use the raw CSS var string directly in PermissionsTable's inline style
instead of the color() helper -- no compile-time benefit was needed here
that outweighs the extra import. Also trims a comment on
PermissionsBasePath's isEmbeddingHub prop that only restated its name.
* refactor: derive the hub color switch from the base path, drop the context
isEmbeddingHub tracked its own state alongside base-path.ts's, always set
together by the same caller -- a second variable with no way to drift
independently. Collapse it into a plain derived check instead of a
context/provider pair.
Also drops PermissionsBasePath's now-redundant admin wrapper: reaching
admin permissions with the hub's base path still set requires a hard
navigation (typed URL, or this app's back button), which reinitializes
the module and needs no explicit reset. Only the hub's own mount/unmount
still needs to touch the singleton.
* test: cover the new table-under-database permissions route
getAdminPermissionsRoutes() gained a route for tables directly under a
database, without a schema segment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8T4ZvTbdRbF85B98maGNh
* fix: carry the design scale renames onto the stack
GDGT-2486 (#80481) renamed the spacing scale in place, mechanically
remapping every call site that already existed on master. This page was
authored on the stack before that landed, so its token is stale -- a
still-valid name that now resolves to a different size.
Spacing md->lg.
Verified against the mapping #80481 applied across its own 1339 files.
CI cannot catch this, so it is a deliberate pass rather than a fix for
a failing check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv
* fix: align permissions chrome with the hub app switcher
The hub places its app switcher 3.5rem from the right edge and its page
title 2.5rem from the left, but the permissions toolbar's help button
ended 1.75rem from the right and the save banner sat at 2rem on both
sides, so neither lined up on the hub's full-width permissions page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoNBWFMfL68mPmofjNq66x
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: add the embedding hub's Tenancy tab (#79511)
* feat: restyle the tenants upsell for the embedding hub
New copy, a new illustration and a left-aligned card, from the embedding
hub's design. `UpsellTenants` is shared: admin People renders the same card
above its people list, so this changes that surface too. That is intended --
the two should not diverge -- and it is split out here rather than buried in
the Tenancy tab commit, which is what actually needs it.
The old artwork had this card as its only reference, so it goes with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp
* feat: add the embedding hub's Tenancy tab
A second view of the tenant surfaces. Tenants live under admin People and
stay there, so the route fragment is mounted a second time rather than
copied. What that costs is not the routes -- it is the roughly thirty
hardcoded /admin/people/tenants strings inside them, which now come from a
`useTenantUrls` context that admin and the hub each provide.
A context rather than the module-level value the Permissions tab uses: these
builders are only ever called from components, while
`getGroupFocusPermissionsUrl` is also called from redux selectors and thunks
that have no component to read a context from.
The tab renders conditionally rather than reusing `createTenantsRouteGuard`:
that guard redirects to /admin/people when tenants are licensed but
`use-tenants` is still off, which is exactly the state a hub user reaches by
clicking a tab that is always visible. So: no licence shows the upsell,
licensed-but-off shows the enable card, both on mounts the fragment.
The enable card opens the user-strategy modal in place rather than routing to
it, because that modal route hangs off the tenants listing, which this branch
of the tab does not render -- the URL would match and nothing would mount.
Two width interactions look like the hub's fault and are not: the listing
centres itself with an auto inline margin, which beats align-items on a flex
item and collapses it, so it gets a child with an explicit width; and its
empty state capped at 700px, narrower than every sibling tab, so the cap is
now the hub's column width.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp
* fix: remove tenant/user URL helpers left dead by createTenantUrls
useTenantUrls and useUserUrls replaced every caller of these; nothing
references them any more.
* fix: fix tenancy page UTM campaign, dead route fallback, and a typo
The docs link used a different UTM campaign than the rest of the hub
(embedding_hub vs embedding-hub), so its clicks were not attributed
with the others. The routes.tsx fallback for PLUGIN_TENANTS.tenantsRoutes
could never render, since EmbeddingHubTenancyPage only mounts an Outlet
once tenants are licensed and enabled. Also fixes a missing "you" in the
enable-tenancy copy, and tags the new unlicensed e2e block @EE to match
its siblings in the file.
* refactor: trim comments to their one-line why
No behavior change; shortens comments that had drifted into restating
the code or arguing a design decision instead of stating the reason.
* fix: add breathing room around the tenants upsell diagram
The diagram frame filled the full width of its bordered box with no
margin, unlike the design. Shrinks the frame ~15% and recenters it
within the same canvas so the artwork carries its own padding, the
same way the shape geometry already did vertically.
* fix: revert tenancy oss e2e block to match its true siblings
An earlier commit tagged this block @EE to match the file's older
unlicensed-SSO convention, but the tab's actual siblings (appearance,
localization, nav) all use a plain "oss" describe with no tag. Matches
those instead.
* test: enable tenancy through the UI instead of the API in e2e
The test only asserted the tenant tabs appear once use-tenants is
true, setting it via H.updateSetting. Drives the enable card and the
Pick a user strategy modal instead, so at least one path through the
actual UI is exercised.
* refactor: rebuild tenant URLs as a module singleton, not a context
createTenantUrls returned a whole object of URL builders bound to a
basePath threaded through React context, so tracing what a given call
produces meant following the provider tree. Every real consumer of
these URLs is a component, same as the permissions editor's, and that
one already solves this with a module-level base path plus plain
exported functions (admin/permissions/utils/base-path.ts). Matches
that shape here: setTenantsBasePath/getTenantsBasePath/
resetTenantsBasePath, called imperatively by whichever host mounts
the tenant routes.
This also removes the two describe/hook layers (TenantUrlsProvider,
useUserUrls) that existed only to have somewhere to call a hook from.
* refactor: keep tenant/user URL functions at their original names
newTenant, editTenant, editUser, deactivateUser, etc. now read the
shared base path directly instead of being deleted and rebuilt behind
a hook -- same signatures every other call site already expects.
* refactor: revert tenant-aware components to plain function calls
These only became components or gained a hook call because a hook
needed a component to run in. Reading the new singleton is a plain
import, so UnsubscribeUserMenuItem goes back to a bare element, and
ReactivateUserButton/TenantsListingEmptyState/
TenantsSummaryOnboardingStep/EditUserStrategySettingsButton go back
to their original arrow-function bodies.
EmbeddingHubTenancyPage sets the base path itself now, the same way
EmbeddingHubPermissionsBasePath does for the permissions editor.
* test: assert on real content after enabling tenancy in e2e
Replaces the toast assertion (flaky) with the tenants listing's own
empty-state copy, and adds a check on the OSS upsell's description
alongside its title, so the test confirms the actual tenants-specific
content rendered, not just any upsell.
* refactor: consolidate tenant URL builders
Reuse the permissions editor's existing setPermissionsBasePath/
getPermissionsBasePath instead of a second, parallel permissionsPath
field on the tenant URL module. Use namespace imports (Urls/TenantUrls)
consistently instead of importing individual functions, matching the
convention already used throughout the urls files. Inline the two
single-use tenantGroupUrl/tenantPeopleUrl helpers at their call sites
now that getTenantsBasePath is exported directly. Preserve the
declaration order of the pre-existing tenant URL functions and the
Urls import alias rather than reshuffling them.
* fix: keep tenants upsell centered in admin
The embedding hub's restyle of UpsellTenants (left-aligned, no
horizontal padding) was applied unconditionally, so admin People's
existing render of the same card lost its centered layout. Add an
align prop, defaulting to admin's original centered layout, and have
the hub opt into the left-aligned one.
* refactor: rename tenant-urls to base-path, matching its sibling
Drops the tautological spec — the round-trip it tested has no
equivalent for the permissions editor's base-path.ts, and the real
behavior is covered by EmbeddingHubTenancyPage's own spec and e2e.
Aligns file name, accessor order, and comment placement to that
sibling, and un-exports ADMIN_TENANTS_BASE_PATH now nothing outside
the module reads it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY
* refactor: use named imports for getTenantsBasePath
The Urls/TenantUrls namespace alias is reserved for actual urls.ts
files; common/tenants isn't one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY
* fix: rename UpsellTenants's location prop to source
Avoids confusion with router location; matches
UpsellEmbeddingTheme/UpsellBigCard/UpsellPill's existing convention
of taking a source prop and mapping it to the tracking location
internally.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY
* refactor: simplify EmbeddingHubTenancyPage from self-review
Reuses SettingsPageWrapper instead of a hand-rolled Stack/Title shell,
matching the Authentication and Security tabs. Renames the local
`root` to `tenancyBasePath`, marks the docs-link icon aria-hidden so
its accessible name is just "Documentation", and simplifies the
route-guard comment. Also asserts on the upsell's description text,
not just its title.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY
* fix: match tab boundaries in TenancyTabs, not raw string prefixes
pathname.startsWith(tab.to) has no segment boundary, so a tenant
whose id starts with "groups" or "people" (:tenantId is a free-form
path param) would light up the wrong tab. Reuses EmbeddingHubLayout's
isUnder, which already solves this for the hub's own top-level tabs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY
* fix: extend timeout for the post-apply tenants-listing assertion
Enabling tenancy through the modal chains two network round trips
before the empty state can render: the use-tenants setting update's
own refetch, then the tenants list query once the listing mounts.
That's tight against Cypress's 4s default under CI load — reproduced
twice in a row in CI, not flaky-random.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY
* fix: match the tenants-listing empty state text with a regex
The prior timeout bump was the wrong diagnosis: the empty state's
copy is interpolated with three inline anchors (jt`...`), so no
single DOM node's text content ever equals the plain string exactly
-- Cypress's default string matcher requires a whole-node match, not
a substring. The CI screenshot showed the text rendered correctly;
the assertion just couldn't find it. A regex matches the substring
instead, which is why it kept failing even at 10s.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY
* fix: align the Tenancy tab's title-to-card gap with its siblings
Dropped the gap="xl" override on SettingsPageWrapper so it uses the
same default gap as the other embedding hub tabs, and added the same
background wash the other upsell illustrations use.
* test: trim duplicate copy assertions from the Tenancy oss e2e test
Card copy is already unit-tested next to the component. What is left is
what needs a real browser -- confirming the upsell renders in place of
the real tenants surfaces.
* fix: keep the reset-password link under the tenants path
UserSuccessModal is mounted both by admin People and by the tenants
routes the hub's Tenancy tab renders, so the hardcoded /admin/people
link sent a hub user into admin. Urls.resetPassword already branches
on isInternalUser via getTenantsBasePath.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014H8Ma3D2dsLvaz4PkpsxTS
* test: count the new table-under-database permissions loader
* fix: carry the design scale renames onto the stack
GDGT-2486 (#80481) renamed the spacing scale in place and dropped the
shadow scale's md key, mechanically remapping every call site that
already existed on master. This page was authored on the stack before
that landed, so its tokens are stale.
Spacing md->lg, lg->xl, xl->xxl, and shadow md->sm -- the shadow scale
has no md key any more, so that card was rendering no shadow at all.
Verified against the mapping #80481 applied across its own 1339 files.
CI cannot catch this, so it is a deliberate pass rather than a fix for
a failing check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: add the embedding hub's Appearance tab (#79512)
* feat: add the embedding hub's Appearance tab
The embedding theme editor plus the appearance settings that show up inside
an embed, on one tab -- there is no separate Themes tab, so the standalone
nav item goes away and its upsell moves here.
Both listing and editor now take their base path from the host so their links
resolve under whichever section mounted them. The listing's own "Themes"
heading is behind `showHeading`, which admin keeps and the hub turns off:
the hub page is titled "Appearance" and puts the grid under a "Themes"
section of its own, and two headings would stack.
The two halves are technically gated on different features -- themes on
`embedding_simple`, the appearance settings on `whitelabel` -- but they
arrive together in practice, so the tab is gated as a whole rather than
building a partial state nobody will see.
`EmbeddedAppearanceSettings` is a deliberate subset: loading message, the
Metabase links toggle and the two empty-state illustrations. Logo, favicon,
login and landing illustrations, instance colours, fonts and application
name stay in admin, since they are not what an embed shows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp
* docs: trim self-review comments in the Appearance tab
Cut the multi-paragraph JSDoc/inline comments down to their factual
core, and drop soft pointers to "the design" in favor of stating the
actual reason.
* fix: lowercase "full-app embedding" in the Appearance banner copy
The phrase sits mid-sentence, not as a heading, so it shouldn't be
capitalized like the Full-app embedding settings card title.
* fix: nest the theme editor route under appearance/theme
New and edit theme URLs were /embedding/appearance/new and
/embedding/appearance/:themeId, colliding with the tab's own path
segment convention. Nest them under a theme segment instead, and make
the editor full-width like the rest of its side-by-side layout instead
of capped to the tab's default 800px.
* fix: match the full-app appearance banner to the design spec
Was background-info with a border; the Figma spec calls for
background-brand with no border.
* fix: link the Get started theme card to the hub's Appearance tab
The Appearance tab now exists, so the card no longer needs to fall
back to the admin theme listing.
* fix: update routes spec for the appearance/theme route nesting
The spec still expected embedding/appearance/:themeId from before the
theme editor route moved under embedding/appearance/theme/:themeId.
* fix: use SettingsPageWrapper for the Appearance tab's title
Was a bespoke Stack + Title with a hardcoded 2.5rem gap, which put the
tab out of step with the other embedding hub tabs' title-to-card
spacing.
* test: count the new table-under-database permissions loader
* fix: load the hub theme editor lazily, behind its token gate
The theme editor route mounted its page eagerly via `element=` with a
static import, instead of the `lazy=` pattern every sibling route
uses. That pulled the EAJS runtime into the main bundle rather than a
lazy chunk, and skipped the `embedding_simple` token gate the
Appearance page applies before showing the editor, so a deep link to
/embedding/appearance/theme/:themeId opened it on plans that cannot
use it.
Add EmbeddingHubThemeEditorPage as a lazy-loaded wrapper that checks
the gate and redirects to the Appearance tab when it's absent, and
route :themeId through it via `lazy=` like the rest of the hub.
Co-Authored-By: Claude Sonnet 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT
* fix: carry the design scale renames onto the stack
GDGT-2486 (#80481) renamed the spacing scale in place, mechanically
remapping every call site that already existed on master. This page was
authored on the stack before that landed, so its tokens are stale --
still-valid names that now resolve to different sizes.
Spacing xs->xxs, md->lg.
Verified against the mapping #80481 applied across its own 1339 files.
CI cannot catch this, so it is a deliberate pass rather than a fix for
a failing check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv
* fix: full-bleed the embedding hub's upsell pages
Review on #79513 flagged the Localization upsell's dotted background as
cropped. Root cause: BaseUpsellPage rendered inside the hub's 800px
capped column, so DottedBackground had no room to fade before its edge,
and LineDecorator's oversized bleed lines got clipped mid-fade.
Fixes it by making a gated tab's whole content area full-width (same
mechanism Permissions already uses) and moving BaseUpsellPage's title
inside the dotted field, so the field owns the whole tab instead of
sitting under a separately-rendered heading. Title and card now share
one fit-content, centered box so their edges always line up, with an
explicit 160px gap between them matching the design.
Authentication, Tenancy and Appearance each get their own hub-local
upsell page through BaseUpsellPage now, instead of reusing admin's
UpsellTenants/UpsellEmbeddingTheme -- those stay untouched, since they
still serve admin surfaces directly and get removed once EMB-1526 drops
the admin embedding routes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WHaGA2f7AsXGCdQmBzsHw
* fix: redirect the bare appearance/theme path back to Appearance
Visiting /embedding/appearance/theme with no theme ID matched the
theme route segment but had nothing to render, leaving a blank page
(flagged in review on #79512). Adds an index redirect back to the
Appearance tab, matching the pattern the hub root already uses.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WHaGA2f7AsXGCdQmBzsHw
* fix: flow the full-app appearance banner's link inline
The Admin link sat in its own nowrap Group next to the sentence, so on
narrow screens the sentence wrapped but the link got pushed onto its
own line at the far edge instead of flowing with the text (flagged in
review on #79512). Renders the whole banner as one Text with the link
inline, so it wraps as a normal sentence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WHaGA2f7AsXGCdQmBzsHw
* test: cover the hub's theme basePath and the editor guard
Three gaps on the Appearance tab. Clicking an existing theme card was
only proven for the New theme button, so a regression sending card
clicks to the admin route would have passed. The theme editor's token
guard had no test at any level, and its route table redirects the bare
`theme` path regardless of plan, so the deep link needs a themeId to
exercise the guard at all. The branding section asserted only the
loading message, not the two illustration widgets beside it.
Also renames the "oss" block to "unlicensed": it runs on the EE jar
with no token, which is the unlicensed state, not an OSS build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZe6AQywXThKuntLQtLt1B
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: use the embedding db helpers in the snowplow payload
The master merge kept two t2/count calls in the modular-embedding event
while dropping the toucan2 require, so the namespace failed to compile.
* test: use the merged embedding flag in the collection browser spec
#80881 landed on master using enable-embedding-sdk, which this branch
folds into enable-embedding-modular.
* feat: add the embedding hub's Localization tab (#79513)
* feat: add the embedding hub's Localization tab
Content translation renders only from the admin embedding section today --
it is not on the admin Localization page -- so this is the one tab that is a
genuine move rather than a second view. The instance-wide localization
settings stay in admin and are not surfaced. Admin's copy goes away with the
rest of the admin embedding section, in EMB-1526.
Below the paywall it shows an upsell, which keeps the hub's tab set the same
seven on every edition. Its copy and illustration are placeholders: no Figma
frame exists yet, so the artwork is a generated placeholder captioned as one
rather than another upsell's, which would make a design that does not exist
look settled. Both are drop-in replacements.
The two specs that need the finished nav -- seven tabs in design order, and
the gem on each paid tab -- land here, because this is the first branch where
all seven exist. They get a nav describe of their own rather than riding
along in a tab's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp
* fix: correct stale comments on the Localization tab
The page comment claimed admin already loses content translation, but
this PR only adds the hub tab -- the admin route is removed separately
by EMB-1526. The upsell comment pointed at a gitignored plan file that
no reader of the merged code can ever open.
* refactor: reuse BaseUpsellPage for the Localization upsell
Was a hand-duplicated copy of the same DottedBackground + LineDecorator
+ UpsellCardContent shape AuthenticationUpsellPage already builds via
BaseUpsellPage, which is also why its title-to-card gap didn't match
its sibling tabs. Also finalizes the description copy, which was a
placeholder.
* fix: replace the Localization upsell's placeholder illustration
Real artwork in place of the generated placeholder box.
* fix: correct backwards duplicate/link wording in Localization's doc comment
The comment described 'duplicates the admin embedding section's UI'
and 'linking to it' the wrong way round: rendering the same settings
component in two places is reuse, and what the other tabs do instead
is genuinely link out to admin.
* test: move nav-completeness and copy assertions from e2e to unit specs
Two e2e tests --reaches all seven tabs from the nav, shows every tab
with a gem on the paid ones-- duplicated coverage routes.unit.spec.tsx
and EmbeddingHubLayout.unit.spec.tsx already had (route existence,
page resolution, nav link order) or could cheaply get (the gem
indicator, now covered by a new EmbeddingHubLayout test). Dropped both
and moved the Localization tab's body copy assertion into its own
unit spec, alongside the title check it already had.
* test: cover the licensed Localization tab in e2e
The tab branches on the content_translation token feature, but only the
upsell half was covered. Add the pro case asserting the dictionary
configuration mounts, and rename the sibling block from "oss" to
"unlicensed" — it runs on the EE jar with no token, which is the
unlicensed state, not an OSS build. Matches the naming the get started
and authentication blocks already use in this file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZe6AQywXThKuntLQtLt1B
* fix: keep the permissions base path across hub tabs
Both the Permissions tab and the Tenancy tab declare the hub as the
permissions editor's URL host, and both reset that on unmount. React
renders the incoming tree before cleaning up the outgoing one, so
navigating between them reset a value the other had just claimed, and
the tenants listing's permissions link fell back to the admin route.
Comparing the stored value could not fix it: both hosts store the same
string. Track which host claimed last with a useId, and let only that
one release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7
* style: wrap the permissions base path setter signature
oxfmt requires it under the line limit.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: update the JWT signing key flow to the approved design (#81068)
* feat: add the embedding hub's Localization tab
Content translation renders only from the admin embedding section today --
it is not on the admin Localization page -- so this is the one tab that is a
genuine move rather than a second view. The instance-wide localization
settings stay in admin and are not surfaced. Admin's copy goes away with the
rest of the admin embedding section, in EMB-1526.
Below the paywall it shows an upsell, which keeps the hub's tab set the same
seven on every edition. Its copy and illustration are placeholders: no Figma
frame exists yet, so the artwork is a generated placeholder captioned as one
rather than another upsell's, which would make a design that does not exist
look settled. Both are drop-in replacements.
The two specs that need the finished nav -- seven tabs in design order, and
the gem on each paid tab -- land here, because this is the first branch where
all seven exist. They get a nav describe of their own rather than riding
along in a tab's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp
* fix: correct stale comments on the Localization tab
The page comment claimed admin already loses content translation, but
this PR only adds the hub tab -- the admin route is removed separately
by EMB-1526. The upsell comment pointed at a gitignored plan file that
no reader of the merged code can ever open.
* refactor: reuse BaseUpsellPage for the Localization upsell
Was a hand-duplicated copy of the same DottedBackground + LineDecorator
+ UpsellCardContent shape AuthenticationUpsellPage already builds via
BaseUpsellPage, which is also why its title-to-card gap didn't match
its sibling tabs. Also finalizes the description copy, which was a
placeholder.
* fix: replace the Localization upsell's placeholder illustration
Real artwork in place of the generated placeholder box.
* fix: correct backwards duplicate/link wording in Localization's doc comment
The comment described 'duplicates the admin embedding section's UI'
and 'linking to it' the wrong way round: rendering the same settings
component in two places is reuse, and what the other tabs do instead
is genuinely link out to admin.
* test: move nav-completeness and copy assertions from e2e to unit specs
Two e2e tests --reaches all seven tabs from the nav, shows every tab
with a gem on the paid ones-- duplicated coverage routes.unit.spec.tsx
and EmbeddingHubLayout.unit.spec.tsx already had (route existence,
page resolution, nav link order) or could cheaply get (the gem
indicator, now covered by a new EmbeddingHubLayout test). Dropped both
and moved the Localization tab's body copy assertion into its own
unit spec, alongside the title check it already had.
* test: cover the licensed Localization tab in e2e
The tab branches on the content_translation token feature, but only the
upsell half was covered. Add the pro case asserting the dictionary
configuration mounts, and rename the sibling block from "oss" to
"unlicensed" — it runs on the EE jar with no token, which is the
unlicensed state, not an OSS build. Matches the naming the get started
and authentication blocks already use in this file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZe6AQywXThKuntLQtLt1B
* fix: keep the permissions base path across hub tabs
Both the Permissions tab and the Tenancy tab declare the hub as the
permissions editor's URL host, and both reset that on unmount. React
renders the incoming tree before cleaning up the outgoing one, so
navigating between them reset a value the other had just claimed, and
the tenants listing's permissions link fell back to the admin route.
Comparing the stored value could not fix it: both hosts store the same
string. Track which host claimed last with a useId, and let only that
one release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7
* style: wrap the permissions base path setter signature
oxfmt requires it under the line limit.
* feat: update the JWT signing key flow to the approved design
Replace the single setup modal with the approved flow: a read-only
generated key shown in "Create a secret key", and a confirm step before
regenerating an existing key.
- read-only key box on one line, icon-only copy button
- "Regenerate key" is now a secondary button
- regenerating asks "Delete key and generate a new one?" first, then
shows the new key in "Store your new key"
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSPdZEZk6NgAeB9C21YWDA
* test: cover the JWT secret key create and regenerate flows
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSPdZEZk6NgAeB9C21YWDA
* fix: always show a freshly generated JWT signing key
Reading the token from the RTK Query cache surfaced the key generated
the last time the modal was open, so a user could copy or save a key
that was about to be replaced. Hold it in local state instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSPdZEZk6NgAeB9C21YWDA
* refactor: reuse ConfirmModal and CopyTextField props in FormSecretKey modals
RegenerateKeyConfirmModal duplicated ConfirmModal's title/message/
confirm/cancel layout instead of using it, and SecretKeyModal hand-rolled
the CopyButton/Tooltip/ActionIcon markup that getCopyTextFieldProps
already provides. Also aligns SecretKeyModal's body spacing, text color,
and cancel button styling with ConfirmModal's conventions.
* fix: prevent dismissing FormSecretKey modals via X, outside click, or escape
The generated key can't be shown again, and the regenerate confirmation
is destructive, so both must be dismissed through an explicit button
rather than accidentally closed.
* fix: don't autofocus the secret key modal's readonly input
Mantine's modal focus trap was landing on the readonly key field by
default, which triggers its focus border color, not any override on
the field itself. Autofocus the confirm button instead, and exclude
the input from focus entirely since it's disabled until the token
loads.
* style: drop border/padding overrides on the secret key input
Mantine's own Input[data-variant="default"] rule already sets a
neutral border color at the same specificity, so the custom border
was a coin-flip fight against the design system rather than a real
override; the default padding already accounts for the copy button's
rightSection too. Keep the CSS specificity hack for the properties
that do need overriding (radius, background, monospace font).
* test: use full-string queries for the new secret key assertions
Only the lines this diff added; existing regex queries elsewhere in
the file are left as-is.
* refactor: rename SecretKeyModal.tsx back to SetupKeyModal.tsx
Keeps the reviewer-facing diff on this branch to a plain modification
instead of a delete+add across an unrelated PR. The component itself
stays named SecretKeyModal.
* refactor: name the has-secret-key check instead of checking value directly
value is a raw secret-key string; using its truthiness inline four
times reads as cryptic. hasSecretKey names what's actually being
checked.
* test: assert the copy button writes the token to the clipboard
The old test only checked the button rendered with no visible label,
which didn't verify copying actually works.
* fix: stop naming an embed type in the shared enable CTA
Enabling embedding via SSO or Guest now flips the same toggle
(enable-embedding-modular), so wording like "enable modular
embedding"/"enable guest embeds" implied a type-scoped action that
no longer exists. Reworded the CTA sentences and toasts to generic
"embedding"; the two terms flags and their legalese stay separate
since SSO and Guest/AGPL are gated to different editions and never
shown to the same admin.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdRnBCJB84JkuZwLrdhsJp
* test: follow the new secret key modal flow in the JWT spec
The set-up modal's confirm button is now "Create", and regenerating
goes through a confirmation modal ("Delete key and generate a new
one?") before the store-new-key modal, so the spec's single "Done"
click no longer matched either step.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8T4ZvTbdRbF85B98maGNh
* test: expect the generic wording in the guest enable CTA
146acfbb6ea reworded the shared CTA to "enable embedding" when the
simple-embed feature is unavailable, which is the case for the guest
card in both the EE-without-token and OSS/Starter specs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8T4ZvTbdRbF85B98maGNh
* test: use the renamed Create button in the hub's JWT spec
The hub's copy of the admin JWT form goes through the same secret key
modal, whose confirm button this branch renamed from Done to Create.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8T4ZvTbdRbF85B98maGNh
* fix: import useMetadataToasts from its new home
Master moved the hook out of metabase/metadata/hooks into
metabase/common/hooks; this file was the last caller left behind.
* fix: close the secret key modal when generation fails
The modal turns off its close button, escape and click-outside so an unsaved
key cannot be dismissed by accident. On a failed `random_token` request no key
ever arrives, so the confirm button stays disabled and the modal has no exit at
all. Closing from the catch keeps the guard for the case it was written for.
Reported by @lorem--ipsum on #81068.
* fix: carry the design scale renames onto the stack
GDGT-2486 (#80481) renamed the spacing and radius scales in place,
mechanically remapping every call site that already existed on master.
This modal was rewritten on the stack before that landed, so the tokens
on its new markup are stale -- still-valid names that now resolve to
different sizes.
Spacing md->lg and lg->xl on the modal, radius md->sm on the secret key
field's CSS module.
Verified against the mapping #80481 applied across its own 1339 files.
CI cannot catch this, so it is a deliberate pass rather than a fix for
a failing check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv
* fix: keep the guest CTA a whole sentence per edition
Interpolating a translated noun into a translated sentence leaves
translators with "To continue, enable %s and agree to…" and no way to
inflect the surrounding words. Give each edition its own complete
sentence instead.
The "agree to the usage conditions" branch loses its edition split
altogether: show-static-embed-terms is force-false on Pro, so
termsAccepted is always true there and the branch never matches.
Updates the three negative assertions in the OSS/starter spec that
still named the old copy, and which no longer guarded anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7
* refactor: leave the modular section comment alone
The file's only change was a reworded doc comment, with no code under
it -- and the new wording invented "SSO-specific terms", which do not
exist: the SSO radio gates on show-modular-embed-terms, the Guest radio
on show-static-embed-terms.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7
* fix: let the keyboard reach the generated secret key
tabIndex={-1} took the field out of the tab order, so the key could not
be read or selected without a mouse. It was not what kept autofocus off
the input: useFocusTrap returns the [data-autofocus] element first, and
that is already the confirm button.
Records why the modal has no escape hatches, and what a hung request
costs, next to the props that cause it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7
* test: cover that the secret key modals resist Escape
All three modals disable the close button, outside clicks and Escape so
a shown key cannot be lost, and nothing asserted it -- the PR asked
reviewers to check it by hand instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7
* style: drop redundant rules on the secret key modals
ConfirmModal already renders its confirm button feedback-negative, and
flex-wrap: nowrap is the default for the flex container the rule itself
declares. Trims the comment above the specificity hack to what the
rules under it actually do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Remove the embedding admin settings and relocate its components (#81018)
* feat: delete the admin embedding route section, redirect to the hub
Deletes the /admin/embedding route subtree now that the embedding hub
replaces it, and adds redirects from every old path to its /embedding
equivalent so no bookmarked or linked URL breaks. Removes the
"Embedding" entry from the admin sidebar/paths list, and the three
route-chrome files that back the deleted section directly
(AdminEmbeddingApp, EmbeddingNav, the admin-side setup-guide page) --
none of them are imported anywhere else.
Sweeps every hardcoded /admin/embedding/... reference this forces:
the SSO wizard's troubleshooting copy, the setup-guide default return
paths, the related-settings links, and the entire e2e suite (15 spec
files). Two e2e specs that only asserted on the deleted admin sidebar
are removed; their edition-specific content checks (OSS/starter
upsell + CORS visibility) are ported into the hub's own security e2e
spec, updated to match its actual (redesigned) upsell banner. Adds
dedicated e2e coverage for the redirect map itself, including the two
backward-compat chained-redirect cases.
Moves the version-pinning card to admin General, the one piece with
no hub-tab home -- and fixes a dropped `embedding_simple` gate in the
process: the original render path required embedding_simple AND
embedding_sdk AND is-hosted?, and the first port of this card only
carried the latter two.
Part 1 of 2 for this PR -- part 2 relocates the components this
section leaves stranded under admin/, once this part isolates the
routing diff.
* refactor: relocate stranded embedding admin components
Moves the components left homeless by commit 1's admin/embedding route
deletion: the theme editor/listing cluster to embedding/themes/, and
the hub-only settings widgets to embedding/settings/.
Along the way, moving the settings widgets surfaced that shared/embedding
cannot import feature/admin (shared is the architectural foundation and
never imports up). SettingHeader, SettingsSection, and AdminSettingInput
had to come out of admin/ too, into a new shared settings-components/
module, so the widgets are self-sufficient. All ~114 consumers across
admin/, enterprise/, and transforms/ were repointed to the new module.
The rest of the old EmbeddingSettings/ cluster (EmbeddingSettings.tsx,
GuestEmbedsSettings, the EmbeddingSecuritySettings wrapper,
EmbeddingSdkSettings, NewEmbedButton, SharedCombinedEmbeddingSettings)
had zero remaining consumers once commit 1 landed, so it's deleted
rather than left as dead weight.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp
* fix: carry the theme editor's app-tier boundary override to its new path
ThemeEditor already had its own app-tier module-boundaries.mjs entry,
predating this PR: it previews the live embed through the app-tier
embedding-iframe-sdk runtime, which has real module-load side effects
(custom element registration, a window.metabaseConfig watcher) and
can't be split into a shared-tier reusable piece.
The previous commit moved the folder but left that entry's pattern
pointing at the old admin/embedding path, so it silently stopped
matching and the files fell through to the general shared/embedding
classification -- caught by a boundaries/element-types lint failure.
Updates the pattern to follow the move, no suppression needed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp
* fix: update EmbeddingHubTenancyPage's SettingsSection import path
SettingsSection moved to metabase/settings-components in this branch's
relocation commit; this import was missed.
* fix: correct theme-editor redirect target to include the /theme segment
/admin/embedding/themes/:themeId redirected to /embedding/appearance/:themeId,
but the theme editor is actually mounted at /embedding/appearance/theme/:themeId
(routes.tsx nests it under a "theme" path segment). The old target matched no
route.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdRnBCJB84JkuZwLrdhsJp
* fix: update EmbeddingHubAppearancePage's SettingsSection import path
The move of SettingsSection to metabase/settings-components missed this
call site, so the Appearance tab failed to resolve the module at runtime.
* refactor: drop the unmounted InteractiveEmbeddingSettingsCard
Removing the embedding admin settings page deleted the only render site
for PLUGIN_ADMIN_SETTINGS.InteractiveEmbeddingSettingsCard, leaving the
slot written but never read. The origins widget it wrapped is still
mounted, by the hub's Security tab.
* fix: return setup wizards to the guide host they came from
The permissions and SSO wizards left the guide with a relative "..",
which resolves against the route, not the host, so a user who entered
from the embedding home page landed in the hub. Carry the host in the
existing returnToEmbeddingSetupGuide param instead, so it also survives
a reload mid-wizard.
* fix: remount a redirect whose target is itself a redirect
Without a key, React reuses the RedirectRoute instance across a
sibling redirect chain, so the first target stays frozen in state and
/admin/embedding/modular never reaches /embedding/security.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8T4ZvTbdRbF85B98maGNh
* fix: repoint DatabaseCachingEditor at the relocated SettingsSection
Master added this importer after the module move, so it still pointed
at metabase/admin/components/SettingsSection, which this branch
deletes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8T4ZvTbdRbF85B98maGNh
* fix: redirect retired embedding paths to the hub
Six legacy redirects still pointed at /admin/embedding and
/admin/embedding/guest, which this branch turns into redirects
themselves. Chained that way the second RedirectRoute reused the
first's instance and froze on the first target, so they were made to
work by keying the shared redirect() helper -- changing behaviour for
every redirect in the app to serve six call sites in one file.
Point the six straight at /embedding/security instead and restore
router/redirect.tsx and its spec. No redirect in the tree targets
another redirect now, and the helper's own spec passes unkeyed.
Cover the whole table rather than a spec per page: admin/routes.unit.spec.tsx
gains the 14 path/target pairs read off the real route tree, plus the
no-chaining invariant. The four legacy paths that had no e2e case at all
join the redirects spec's table, which now covers all 14.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT
* fix: default the theme editor base path to the hub
The editor's basePath still fell back to /admin/embedding/themes, a
route this branch deletes, while its ThemeListing sibling had already
been moved onto the hub path. Only the hub mounts the editor and it
passes basePath explicitly, so nothing was broken -- but cancel and
delete would have navigated through a redirect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT
* docs: trim the module boundary comments
The theme-editor note grew from five lines to ten while restating the
move it documents; the settings-components and embedding-hub ones
listed ticket history the code does not need. Keep the reason each
carve-out exists and drop the narrative around it.
Every line touched here is a comment this branch itself added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT
* refactor: drop the -setup suffix from the wizard paths
get-started/permissions-setup and get-started/sso-setup stutter under a
parent segment that already says setup. Rename them to
get-started/permissions and get-started/sso.
Safe to rename rather than redirect: the hub reaches master only when
embedding-hub-main merges, and that PR is still open, so no released
Metabase has ever served these URLs.
Sharing a final segment with the /embedding/permissions tab is fine --
different parent -- and EmbeddingHubLayout's "does not light up
Permissions on the permissions wizard" test covers exactly that.
The two hub routes come from an earlier PR in this stack; they are
touched here because this branch's redirects point at them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT
* test: restore the OSS and starter nav checks, fix the upsell
The deleted embedding-admin-settings-{oss,starter} specs each checked
which links their edition's sidebar offered. That sidebar is gone; the
hub shows every tab to every edition and marks the unlicensed ones with
a gem, so assert that instead, once per edition as before.
The OSS upsell test asserted a button and so could never pass. OSS
leaves PLUGIN_ADMIN_SETTINGS.useUpsellFlow returning no trigger, which
sends UpsellCta down its url branch to an ExternalLink; paid builds wire
the trigger up and get an UnstyledButton. Assert the link.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT
* test: assert against the hub layout, not admin
The embedding pages now render inside the embedding hub, so the specs
look for embedding-hub-main and the "Embedding hub" nav instead of
admin-layout-content and admin-layout-sidebar.
* test: visit the content localization page
The content translation settings moved off /embedding/security onto
their own /embedding/localization page.
* test: follow the theme editor to its hub route
The editor moved from /admin/embedding/themes/:id to
/embedding/appearance/theme/:id, and it renders full width rather than
inside the admin sidebar layout.
* test: start New embed from the hub nav
The button now lives in the hub nav rather than in the SDK and guest
embed setting cards, so the specs share one clickNewEmbedButton helper.
The guest-only entry point is gone with those cards, and with it the
assertion that it forces guest auth.
* test: drop the modular embedding settings spec
It asserted on the Tenants link inside the settings card, which the
embedding admin settings removal took with it.
* fix: drop the modular embedding gate on version pinning
Version pinning is about the SDK package, so the SDK feature and a
hosted instance are the only conditions it needs.
* docs: say why theme-editor is app tier
Name the import chain that forces it, and why settings-components is
separate from the data-only settings module.
* docs: correct the no-literal-metabase-strings reasons
The strings are exempt because only admins see them, not because they
are legal copy.
* docs: note where the theme upsell coverage went
The component no longer gates itself, so its upsell tests live in
theme-upsell.cy.spec.ts.
* fix: import AdminSettingInput from its relocated path
Master added the fast-mode input to this section after the relocation
commit was written, so the new import still pointed at the old widgets
directory this branch empties.
* fix: redirect the setup guide straight to the hub
/admin/embedding/setup-guide pointed at /embedding, which is itself a
redirect to /embedding/get-started. Every other retired path lands on a
real page, so send this one there directly too.
* refactor: require basePath on the theme editor and listing
Both defaulted basePath for a second host that no longer exists, and the
move to the hub left each default pointing at the other one's path: the
editor would have returned to /embedding/appearance/theme and the listing
would have created themes at /embedding/appearance/new, neither of which
routes anywhere. The hub always passes basePath, so drop the defaults.
* test: assert every hub tab from one configuration
The per-edition nav checks only asserted the four gated tabs, so a gem
appearing on an ungated one went unnoticed. List all seven with their
expected gem state instead, shared by the OSS and Starter blocks.
* test: cut the admin redirect spec to two browser cases
admin/routes.unit.spec.tsx already asserts the whole retired-path table
off the route tree. Keep only what reading the tree cannot show -- that a
redirect fires in a browser and carries its params -- instead of a restore
per path.
* docs: state where the New embed button lives now
The comment contrasted the hub nav against settings cards that no longer
exist, which goes stale as soon as nobody remembers them.
* fix: point the theme editor page at the relocated module
This branch moves the theme editor components from
metabase/admin/embedding/components/ThemeEditor to
metabase/embedding/themes/components/ThemeEditor. The hub's lazy
theme editor page still imported the old path, which the rebase
onto this branch left broken since git has no way to track an
import string across a rename.
Co-Authored-By: Claude Sonnet 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT
* fix: drop the stray unused ThemeEditor import in routes.tsx
The ThemeEditor relocation commit's routes.tsx hunk updated a
top-level `import { EmbeddingThemeEditorApp } from ...` line to its
new path. That line had already been deleted when the theme editor
route switched to a `lazy=` loader, so rebasing the relocation
commit onto that fix reintroduced it as an unused import, since git
applied the patch by fuzzy context match rather than conflicting.
Co-Authored-By: Claude Sonnet 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT
* fix: align embedding e2e specs with the hub pages
Repointing these specs from the admin embedding pages to the hub was a
URL and test-id swap, so assertions that the hub renders differently
were left behind:
- The Get started card reads "Configure data permissions and tenants";
only the permissions page kept "and enable tenants".
- The Security page's upsell passes an onClick, so UpsellCta renders a
button rather than an anchor and the upgrade URL is not in the DOM.
- Published guest embeds render only when embeds exist, so unpublishing
removes the section instead of showing empty states.
- The tenants links are gone: tenancy off opens the user-strategy modal
in place and tenancy on renders a tab to the hub's own tenancy page,
both covered by EmbeddingHubTenancyPage.unit.spec.tsx.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016amK37aKSDk7sgJsT3AVJQ
* fix: match the hub's checklist card DOM in setup-guide e2e
The migration to the hub kept the admin card's DOM contract. A hub card
is an anchor, a button or a div depending on whether the step links,
opens a modal or is locked, so closest("button") only matched a third of
them; its done badge is a check icon labelled "Step N complete" with no
"Done" text; a locked card carries aria-disabled and a hover tooltip
rather than a lock icon; and the SSO card is titled "Set up SSO".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016amK37aKSDk7sgJsT3AVJQ
* fix: drop the stale settings wait in content translation e2e
The dictionary upload helper waited on GET /api/setting after visiting
the localization page. The admin page fetched it on load and the hub's
Localization tab does not, so the wait timed out and took the five
upload-form tests with it. The findByTestId assertion that follows
already retries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016amK37aKSDk7sgJsT3AVJQ
* test: assert the OSS upsell renders a link, not a button
The hub's Security page takes its click handler from
PLUGIN_ADMIN_SETTINGS.useUpsellFlow, whose OSS stub returns
triggerUpsellFlow: undefined. UpsellBanner then falls back to
useUpgradeAction, which also returns onClick: undefined off a hosted
instance, so UpsellCta matches its url branch and renders an anchor.
An OSS bundle can therefore never render this CTA as a button. The
assertion passed locally only because build-hot bundles the enterprise
plugins regardless of MB_EDITION; CI's oss-subset job builds the real
OSS bundle and got a link.
Restores the href assertion the migration dropped, with the hub's
campaign and location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv
* test: await the throttled recents before asserting the embed
The spec aliases the throttled /api/activity/recents response as
@getRecents but never waits on it. That worked while visitNewEmbedPage
clicked a card on /admin/embedding, which only rendered once that page
had loaded its settings -- several seconds in which the throttled
response made progress.
The hub's "New embed" button is in the nav and is clickable as soon as
the layout mounts, so the wizard now opens immediately and the response
is still in flight when the assertions run. CI shows neither dashboard
title present, not the example dashboard, so this is the wizard still
resolving rather than a fallback.
Awaiting the alias restores what the test name already promises.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv
* fix: order the embedding imports as import/order expects
eslint import/order wants metabase/embedding/themes before
metabase/embedding-hub/upsells. Fixes the fe-lint CI failure.
* fix: restore the Box import on the JWT form
The master merge's import resolution dropped Box while the group-schema
block still renders it.
* fix: repoint DomainsSettingsPage at the relocated modules
Master added this page against the pre-move paths for SettingsSection,
SettingHeader and isSettingSetFromEnvVar.
* test: restore the csp-img and custom-viz mock settings
setup() indexes both off generalSettings; master's copy of the mock
dropped them.
* style: order the relocated settings-components imports
import/order after repointing them at the new module.
* fix: drop the settings left behind on the general page
csp-img-enabled and custom-viz-enabled render on the domains page now,
so their reads here were dead, along with the imports they needed.
* style: drop the blank line eslint left in the import block
* refactor: query the hub content by its main landmark
The hub's content area is the main content of the page, so mark it up as
one instead of reaching for it by test id. Drops embedding-hub-main in
favour of component="main" and repoints all 45 call sites.
Kept on the outer Box rather than AreaContent, which the monitor layout
also renders -- tagging it there would give that page a main landmark
too. MonitorContent still uses monitor-main on its equivalent Box.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4
* test: assert the checklist step number explicitly
The label carries the step's position, so matching /^Step \d+ complete$/
proved only that some badge was there. Every one of these sites runs
under a pro token, so the numbers come from the licensed order.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4
* Revert "refactor: query the hub content by its main landmark"
This reverts commit 667b15013d.
* refactor: give settings-components a public API
The module is new in this PR, so every consumer's import line is already
in the diff -- the cheapest moment to add the barrel its shared-tier
neighbours settings and segments already enforce.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4
* refactor: exempt the relocated modules in the eslint config
Leaving admin/ drops these files out of the exemption that turns the
metabase-string and color-literal rules off, and embedding-hub is
already on that list. Two files go back to matching their originals
byte for byte -- an inline comment forced a JSX reflow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4
* fix: validate the setup guide return path
The parameter went unchecked into a link target, so a crafted url
rendered a working off-instance link on an admin page and an empty
one rendered a dead link whose onClick still marked the step done.
Uses the same origin check as the login redirect parameter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4
* fix: build the return-to link with URLSearchParams
The old concatenation appended a literal "?" whether or not the step's
own path already carried a query string.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4
* refactor: drop the code the admin removal stranded
The deleted pages were the only callers of UpsellSdkLink, the related
settings helpers and their section wrapper; the theme listing's own
gate was the only caller of UpsellEmbeddingTheme. ColorRow had none.
RelatedSettingCard stays -- the tenants onboarding step uses it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4
* refactor: drop showHeading from the theme listing
The admin route that mounted the listing as a whole page is gone, so
its one remaining caller always passes false and the heading branch
never renders. The Appearance page titles the section itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4
* test: trim dead setup and restore the sdk plugin stub
Three setup parameters and two mock settings had no caller once the
csp-img and custom-viz reads moved to the domains page. The plugin
stub was assigned without ever being put back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4
* test: tighten the hub tab and locked step assertions
assertHubTabs compared one hardcoded absent tab; counting the links
makes the caller's list exhaustive instead. The locked production
step now asserts its tooltip, which replaced the old inline reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4
* docs: correct three stale comments
The widgets folder is no longer a sibling of AdminSettingInput, the
EMB-2229 sentence promised a tier change that branch does not make,
and the smoketest assertion needed the reason it expects nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4
* test: keep the no-fetch assertion for the appearance upsell
The theme listing spec asserted it before its token gate moved to the
page; assert it where the gate lives now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* Move the embedding hub module from app tier to feature tier (#81019)
* refactor: move what the hub shares with admin into common
After the tier flip admin and the embedding hub are both feature tier,
and a feature module may not import another, so anything both read has
to surface into a shared module.
base-path.ts is that: six admin files, three enterprise files and two
hub files read it, so it moves to common along with its spec. The
public-links table moves for the same reason -- admin's Public sharing
page and EmbeddedResources both render it, and EmbeddedResources itself
moves to embedding/settings since only the hub's Security page uses it.
EmbeddingHubPermissionsBasePath hardcodes the hub's path and is read by
the hub alone, so it moves into the hub rather than into common. Every
other file here is a one to three line import repoint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XchaaoRd76S9WU3NzhqKW
* refactor: flip the embedding hub to feature tier
The hub's only cross-tier imports are routes.tsx reaching admin's
permissions routes and EmbeddingHubThemeEditorPage mounting the app-tier
theme editor. Both go into an app-tier embedding-hub-routes element
ahead of the feature one, the same carve-out admin-routes used, so the
rest of the module can flip.
Drops admin-routes itself, now dead. #80146 created it solely so
admin/routes.tsx could lazy-mount that same theme editor, replacing a
feature/admin -> app/theme-editor whitelist entry; #81018 then moved the
editor to embedding/themes and left admin with plain redirects, so
nothing in admin/routes.tsx reaches app tier any more. This ticket is
where that edge resurfaces on the hub.
Verified against both rule sets, which are built separately:
module-boundaries stays at 32 problems with no admin/routes finding, and
lint-eslint-pure, which loads enforcedRules, exits 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XchaaoRd76S9WU3NzhqKW
* docs: drop the stale tenants base path contrast
The comment said this module lives in common, unlike the permissions
editor's equivalent, because it is read from both OSS and EE call sites.
Both halves are now false: this PR moves that equivalent into common
too, and it is read from enterprise as well. The contrast distinguishes
nothing, so it goes rather than getting reworded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XchaaoRd76S9WU3NzhqKW
* fix: keep the theme editor page off the pages barrel
The page sits in the app-tier carve-out so it can import
EmbeddingThemeEditorApp, but pages/index.ts is feature-tier and
re-exported it. The boundary lint does not follow re-exports, so a
feature-tier file could reach the app tier through the barrel: a direct
import errors, the barrel import passed silently.
routes.tsx now imports the file directly, which is legal because
routes.tsx is itself in the carve-out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgJHnFajYcKBARP8J33ZmR
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: name the embedding hub as the seeding trigger
The hook moved behind EmbeddingHubLayout, so it now fires on first visit
to the hub rather than to the themes listing page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PzJNGUUd5hZjQjdSjHC6mV
* refactor: move the embedding hub's Toucan 2 calls into a db namespace
Master added a kondo rule keeping Toucan 2 query calls in module db
namespaces (#82099). The hub's OSS module predates it, so merging master
into the integration branch left 17 warnings in api.clj and a red
Clj-Kondo job.
Extracts the queries verbatim into metabase.embedding-hub.db, mirroring
metabase.embedding-rest.db.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PzJNGUUd5hZjQjdSjHC6mV
* fix: refetch the setup guide checklist after a single setting write
The checklist query is invalidated by updateSettings but not by
updateSetting, so a step written through useAdminSetting -- the AI one --
stayed grey until the page was reloaded.
Tagging the singular mutation rather than the AI page fixes every step
written that way. It costs nothing on instances that never open the hub:
RTK Query only refetches subscribed queries, and updateSetting is already
the pessimistic admin path that refetches session-properties on each call.
The per-click writes go through updateUserSetting, which is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PzJNGUUd5hZjQjdSjHC6mV
* fix: return to the setup guide after creating a theme from it
The editor always went back to the Appearance listing, so the setup guide's
theme step was a one-way trip. It now takes an optional returnPath, and the
hub page sets it from a ?return=get-started marker on the link.
The step links straight into a new theme rather than the listing, so the
return lands where the user actually came from. Only the one known marker
value is honored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PzJNGUUd5hZjQjdSjHC6mV
* fix: satisfy the frontend linter on the hub specs
The theme editor's spec imports the page, which is app-tier because it
mounts EmbeddingThemeEditorApp, so the spec is tagged the same way its
routes.unit.spec.tsx sibling already is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: reorder the apps in the app switcher
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: drop the tenants docs icon inside the embedding hub
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: restyle the New embed nav button
Primary rather than secondary, so it does not get lost on the page. And
collapsed, where it sets the nav rail's width, it takes the same 32px box
as the tabs beside it instead of a taller, wider button.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA
* fix: state the modular embedding terms for guest embeds
Enabling guest embeds writes the same enable-embedding-modular setting
the SSO path does, so a Pro admin ended up with modular embedding on
without ever being shown its terms.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: turn tenancy on straight from the embedding hub
The button said "Enable multi-tenancy" and then asked which strategy to
use, so the choice was already made twice. It now writes the setting and
the page renders the listing, which is what the hub can do and admin
cannot: admin's own entry point still opens the modal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA
* fix: drop the tenant headings the hub tab already names
Under the hub's Tenancy tab each sub-tab repeated its own name as a page
heading, so the tab bar and the heading said the same word. The listings
take a showTitle prop to leave it out; admin passes nothing and keeps its
headings.
Which host mounted the tenant routes is now one isEmbeddingHubTenancy
helper, mirroring the permissions editor's isEmbeddingHubPermissions,
rather than the base-path comparison the docs button had inlined.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA
* fix: drop the tenancy invalidation the settings API does
updateSetting already invalidates setup-guide-checklist on every write and
permissions-group when the key is use-tenants, so the hub was asking for
both a second time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA
* docs: point the tenancy hub check at its permissions twin
Two files now share the name is-embedding-hub.ts, and nothing said the
second was deliberate rather than a duplicate to collapse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA
* refactor: ask for the tenancy host where the permissions one does
The listings took a showTitle prop fed by the tenant wrappers, while the
permissions editor's components call their own check inline. Follow that,
so the two hub-aware subsystems read the same, and the wrappers go back to
forwarding nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA
* fix: validate the setup-guide return path before navigating
The param is read straight from the URL and handed to navigate, while the
sibling that reads the same param checks it against the site origin first.
Router normalization happens to make an off-origin value land same-origin
today, which is not something to rely on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA
* style: format the JWT spec after the merge resolution
The merge commit skipped the hooks so master's files would not be
reformatted wholesale, which also skipped the two files resolved by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA
* test: drop the strategy modal step from the tenancy spec
The hub's Enable multi-tenancy button writes use-tenants directly and
lands on the listing; only admin's own entry point still opens the
strategy modal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA
* test: treat an already-enabled terms card as accepted
A section that mounted on stale settings stays on screen with its
disabled Enabled button, so keying the early return on the card's
presence made the helper look for an accept button that was gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA
* test: assert the embed preview loader before accepting terms
On Pro, the Guest radio now renders the modular embedding terms card, so
the helper's terms round-trip runs between opening the modal and the
loader assertion. The preview mounts at modal open and its iframe
finishes inside that window, so the loader had already unmounted by the
time the assertion started.
Asserting the loader immediately after the modal opens restores master's
timing while keeping the terms click.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: call useUpsellFlow only when the upsell renders
The hook sets window.name in a useEffect, which renames the Cypress AUT
frame. Any page that mounted it broke cy.press even on Pro, where no
upsell is shown -- embed-parameters.cy.spec.ts failed with "Could not
find AUT frame". Both hub pages already gate the banner on the token
feature, so moving the hook into the banner keeps it out of the Pro path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyreUcTwdriuiQ3ngskSpF
* fix: match the designs in the embedding hub layout
Per design review on #80336: the theme editor is full screen again, with
no nav sidebar and no app switcher, and the sidebar's Permissions entry
uses the eye icon the designs call for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyreUcTwdriuiQ3ngskSpF
* fix: keep the secret key input controlled when cleared
Clearing the field saves an empty value, and the backend then reports the
setting as present-but-nil, so /api/session/properties sends null and the
input's value went from a string to null. React flagged the controlled to
uncontrolled switch on every clear.
The two other reads of the setting behave the same either way; they use
the normalized value so nothing in the component sees the raw null.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017H2PEghttWPv6wFmLQfEdu
* chore: regenerate modules config after master merge
The merge dropped queries_rest's metabase.embedding.validation require:
this branch removed the check-embedding-enabled gate on the embeddable
cards endpoint, and master removed check-allowed-to-change-embedding.
With no call sites left, queries.rest no longer uses embedding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SnFSpp92VDRya282ZbCWu
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* disable explorations
* fix e2e
* Keep the explorations API tests running while the routes are unmounted
The explorations routes are intentionally left out of the application's
route map, so every `mt/user-http-request` against `/api/exploration/...`
in `metabase.explorations.api-test` and
`metabase-enterprise.sandbox.explorations-test` returned 404.
Let the test HTTP handler be overridden per test: `metabase.server.test-handler`
gains a public `make-test-handler` that takes the API routes to serve, a
bindable `*test-handler*`, and `do-with-api-routes`. A new
`metabase.explorations.test-util` mounts `/api/exploration` in front of the
application's routes (wrapped with `+auth` as the route map used to), and both
namespaces install it as an `:each` fixture.
Also assert that the application itself still 404s on `/api/exploration`, and
regenerate the modules config, which no longer lists
`metabase.explorations.api` as an exported API namespace.
* Keep the explorations Metabot profile unregistered while explorations are disabled
The agent endpoint accepted profile_id "explorations" even though
nothing on the branch mounts the feature, so the profile's tools and
prompt were still reachable over the API. Define the profile without
registering it, and reject an unregistered profile with a 400 before a
turn is persisted or the title job calls the LLM. Tests that exercise
the profile register it through a new test-util helper.
* Close the listing surfaces that still handed out residue exploration rows
Explorations are disabled, but an instance upgraded past this version,
used to create explorations, then downgraded keeps its exploration
tables. Four listing surfaces still handed those rows to clients. Not
anymore!
- Search: "exploration" joins the excluded-models set and leaves the
models search order, so it drops out of the searchable model set.
Both engines filter stale index rows at query time, a models filter
naming it is rejected, and it is no longer an available model.
Ingestion and the shared API model map are untouched.
- Collection items: dropped from the default model list and the models
parameter allowlist, so it is neither listed nor requestable.
- Bookmarks: the exploration arm leaves the bookmark union and the type
leaves the bookmarkable models, so listing omits it and create/delete
no longer match a route (404, like any unknown model).
- Comments: dropped from the API type map, from which the request
schema derives, so exploration targets are rejected.
The disabled-state tests now live together in
metabase.explorations.disabled-test: the routes 404 and Metabot profile
400 tests move there from the explorations and metabot API tests, and
one test per closed surface joins them. Tests that asserted explorations
were listed by these surfaces are removed; the comment-context gate test
runs the registered gate directly, since the comments API no longer
serves exploration targets; the exploration model's search test keeps
only its spec-registration check.
Frontend callers that still send the model (the collection content
all-models constant) get a 400 from the items endpoints until that
constant drops it.
* Stop the collection page from requesting the exploration model
* review feedback
---------
Co-authored-by: John Swanson <john.swanson@metabase.com>
* LDAP and Password auto log out when mfa enabled
* Fix lint
* Update core.clj
* Update gate_test.clj
* Update session_test.clj
* Require MFA enrollment on login when it is `:required` but user is not enrolled
* Tidying up, fixing unit tests
* Update session.clj
* Update session_test.clj
* Associate MFA Auth Identity with session
* Check for present mfa auth
* Uxw 4972 login introduce 2fa enrollment on login fe (#79485)
* updating admin setting
* MFA enrollment on login
* grace period setting
* Recovery Codes
* ..
* .
* ..
* auto grace period, clear button
* starting e2e coverage for enforcement
* Propagate MFA id to new session after getting recovery codes
* SSO mfa e2e tests, FE polish
* Update session.clj
* Update core.clj
* Revert "Update core.clj"
This reverts commit 5474ba3ea4.
* Tests passing aside from my new ones for password reset
* Fix `apply-mfa-gate` logic for SSO logins while MFA is required
* Fix MFA session checks post BE SQLi hardening
One of the `:raw` HoneySQL clauses was missing the `^:allow-raw-sql`
annotation. Such illegal SQL clauses throw 500s in dev mode, but in
prod and test modes they are replaced with `TRUE` or a similar dummy
clause. So the MFA enforcement conditions in the session middleware
were reduced as follows:
- intended: `mfa_auth_identity_id IS NOT NULL OR (auth_identity.provider <> 'password' AND auth_identity.provider <> 'ldap')`
- sanitized: `mfa_auth_identity_id IS NOT NULL OR (TRUE AND TRUE)`
which is equivalent to `TRUE`, nerfing the check completely!
This commit brings back the enforcement.
* more e2e
* comment cleanup
* UX adjustment
* disable required options for non enrolled admins
* confirmation modal
* PR Feedback
* Derive all the new MFA-related audit events from `::audit-log/user-event`
* FE lint and unit tests
* happy e2e
* auth card changes
* Addressing BE review comments; thanks Mike
* BE lint and formatter fixes
* fix up ::provider/supports-mfa usages to use the custom auth-identity hierarchy
* type check fixes
* Keep up with the security hardening: `:closed` input schemas
* Kevin feedback
* Propagate MFA information to the new session after changing your password (#82123)
* Propagate session id into user info
* Update api_test.clj
Add tests
Revert "Update api_test.clj"
This reverts commit 9bd4fce7400ca2fcf9e2760746ea0b6f0c67e65c.
* Fix lints
* Update db.clj
* Update api_test.clj
* Update src/metabase/users_rest/api.clj
Co-authored-by: Braden Shepherdson <braden@metabase.com>
* Rename query
* Update api_test.clj
---------
Co-authored-by: Braden Shepherdson <braden@metabase.com>
* enable the saml container in CI
* typo
* BE unit test fixes
* Add a response schema for our new endpoint `/api/session/mfa/enroll`
There's a ratchet making sure the number of ignores for `defendpoint`s
without response schemas does not increase, so a new endpoint is forced
to include one.
* fix OSS tests
* Revert "fix OSS tests"
This reverts commit 0515db4409.
* Move mfa-enforcement setting into OSS so it functions in OSS tests
It cannot be set to values other than `:off` in OSS, and there are no
MFA providers in OSS either, so it cannot be used.
* fix some dangling references
* green CI
---------
Co-authored-by: Ethan McCue <ethan@mccue.dev>
Co-authored-by: Nick Fitzpatrick <nick@metabase.com>
Co-authored-by: Nick Fitzpatrick <nickfitz.582@gmail.com>
The bot used to GET whatever url_private a file_share event carried, with the
workspace bot token attached, so a remote file could point it at any address the
registering app chose. It now refuses anything that isn't an https Slack file
host, turns remote files away before download, follows at most one redirect and
only to another Slack file host, and holds the size limit as the bytes arrive
rather than trusting the size on the event.
* Enable the feature the unknown-parameter test needs to reach its check
`resolve-parameters` refuses any `parameters` at all without `dashboard-subscription-filters`, and
that refusal runs before the unknown-id check. The teaching-error test supplied a bad id without
enabling the feature, so whenever it ran without ambient token state the call failed on the feature
and never reached the check the test exists to pin — both assertions red, which is what CI hit.
Wrapped in `mt/with-premium-features`, matching the two sibling parameter tests, which already pin
their own token state rather than inheriting it.
Verified fails-when-broken: swapping the feature set to `#{}` fails both assertions.
Claude-Session: https://claude.ai/code/session_018PNCQxp9kCypqE3dLQS9va
* [mcp-v2 12] definitions: cover the write-scope readback degradation
Both tools echo the created/updated row, which carries its name and
definition — so a token holding only `agent:content:write` would learn
the content of anything it may touch. `v2.write/readback` degrades that
echo to an ack, and all four call sites here relied on it untested.
`readback-requires-read-scope-test` drives create and update for both
tools with the write scope alone, asserting the payload is exactly
`{id, note}` and the note names the missing read scope. It then reads the
row from the DB to show the write itself still landed, and repeats the
update with both scopes to show the full row comes back — so the
assertion cannot pass merely because the tool returned something thin.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbCo3ybRitdVSQXYcG97QR
* [mcp-v2 12] definitions: reject a blank name
Both tools declare `name` as `[:string {:min 1}]`, which " " satisfies,
so a whitespace-only name was accepted and stored — laxer than the REST
endpoints whose checks these tools otherwise inherit, which take
`ms/NonBlankString`. The JSON schema the MCP manifest is built from
cannot express non-blank, so the check belongs in the handler, beside
the `str/blank?` check `revision_message` already gets.
`check-name!` runs in all four branches immediately after the method-args
check, before any id resolution — so a blank name on update reads as an
argument error rather than "not found".
`blank-name-test` was written first and watched fail: the create half
stored `{"id":1,"name":" ", ...}`, and the update half returned the
not-found message because the lookup preceded any name check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbCo3ybRitdVSQXYcG97QR
* [mcp-v2 12] definitions: cover clearing a description
`clear` is the only way to unset a property — a null cannot carry that
meaning, since strict clients fill every unset property with null and the
registry strips nulls at the boundary. `dispatch-write`'s expansion is
unit-tested in `write_test`, but nothing checked that a cleared property
actually reaches the column through these two tools.
`clear-description-test` creates each entity with a description, clears
it, and asserts both that the column is null and that the key is absent
from the echo — the concise projection drops nils rather than echoing
null back.
It also pins where a non-clearable property is refused: `clear`'s schema
is `[:sequential [:enum "description"]]`, so that is rejected as an
invalid argument before `expand-clear` runs, making its own "can't be
cleared" message unreachable for these two tools.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbCo3ybRitdVSQXYcG97QR
* MCP v2 get_parameter_values: reject date constraints chain filtering can't parse
`check-constraints!` promised to reject any constraint chain filtering would
silently drop, but covered only the three field-shaped drop paths. The fourth is
in the value: `chain-filter/add-filter` routes a string value on a temporal field
through `params.dates/date-string->filter` and catches a parse failure into
`nil`, dropping that filter and running the query unnarrowed.
Proven before fixing — a `_DATE_` constraint of "sometime last spring" against a
checkins dashboard was accepted and returned all 100 venues with
`has_more_values false`: a completely unfiltered list handed back as a filtered
one. That is the failure this tool exists to prevent, and a date string an LLM
would plausibly produce is the likeliest way to hit it.
The check runs `date-string->filter` itself, so it can't disagree with
`add-filter` about what parses — the same "probe with the real thing" property
the reachability check gets from `filterable-field-ids`. Temporal-ness is tested
as `effective_type` (falling back to `base_type`) against `:type/Temporal`,
matching `lib.types.isa/temporal?` on the column `instance->metadata` builds. It
is scoped exactly to `add-filter`'s branch — bare strings only, per field-id, so
a multi-field constraint is judged per field the way the fetch judges it. The
error names the grammar that does parse rather than only refusing.
Also fixes an adjacent misdiagnosis in the same fn: when the target parameter has
no field-ids at all, chain filtering falls back to `filter-values-from-field-refs`,
which ignores constraints outright. That was reported as "no join path to this
parameter's table", which sends the reader looking for a missing FK. It now names
the real cause.
parameters-test 32 tests / 89 assertions; :module parameters 151t / 385a;
fix-modules-config unchanged; kondo 0/0; cljfmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJcw6ZamZBNhJ8SQxgJB13
* MCP v2 get_parameter_values: cover constraints and query applied together
The tool's most involved path had no test. `constraints-test` passes constraints
with no query and `query-search-test` passes a query with no constraints, so
nothing reached `chain-filter-search` with a non-empty constraint set — the one
route that also pins `chain-filter/*allow-implicit-uuid-field-remapping*` false.
The test asserts the two narrowings compose rather than merely co-exist: a
category that matches the query but is excluded by the constraint must be absent.
Searching "African" alone finds it; searching "African" under price=4 returns
nothing, which fails if the constraint is dropped once a query is present.
No behaviour change — the path was already correct. This closes the coverage hole
so it stays that way.
parameters-test 33 tests / 92 assertions; kondo 0/0; cljfmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJcw6ZamZBNhJ8SQxgJB13
* MCP v2 get_parameter_values: blame the search when a query matches nothing
An empty result carried one sentence regardless of how it was reached: "its
source may be empty, filtered to nothing for you, or a free-text filter with no
value list." Every cause it offers is a property of the parameter, so after a
search that simply missed, the agent is told the filter may be broken when the
actual recovery is to search for something else — and none of the three listed
causes is even true.
`steering-line` now takes the caller's `query` and, when there was one, names it
and points at the recovery that fits: search for less, or drop `query` to list
everything. The generic sentence still covers the no-query case, where those
three causes really are the candidates.
Threading `query` into `values-content` is the only structural change; the
slicing and the other three branches are untouched.
Covered by no-match-for-query-blames-the-query-test, which asserts on both
targets that the line quotes the search and drops the causes that can't explain
it.
parameters-test 34 tests / 98 assertions; kondo 0/0; cljfmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJcw6ZamZBNhJ8SQxgJB13
* Fix archived document hits being dropped from archived searches
validate-and-enrich-documents re-fetched document hits with a hardcoded
`:archived false`, so any document matched by an `archived: true` search
failed that lookup and was dropped by the `keep` — while `:total` still
counted it. The result was an empty `data` alongside a nonzero total that
no offset could ever reach.
The predicate now takes the archived state the search asked for:
`validate-and-enrich-documents` gains an `archived?` first argument and
selects `:archived (boolean archived?)`, with the call site in `search`
passing `(boolean archived)`.
Reachable only since the v2 search tool started exposing `archived`
through this pipeline; documents are archivable (`:archived true` in the
document search spec), so both `{archived: true, type: ["document"]}` and
an unscoped `{archived: true}` lost every document hit.
Test proven red before the fix (parameter added, hardcoded `false` kept):
archived hit dropped (expected [id], actual ()), and an active hit wrongly
retained for an archived search. The new test pins both directions.
Verified: metabot.tools.search-test + mcp.v2.tools.search-test +
mcp.v2.common-test 72 tests / 270 assertions / 0 failures; kondo 0/0;
cljfmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa
* Test that only a superuser's search reaches the engine for transforms
This pipeline dropped remove-unreadable-transforms and relies instead on
the transform search spec's `:visibility :superuser`, which the engine
applies when it narrows a context to its applicable models. That holds
only if the pipeline hands the engine the caller's real superuser status,
and nothing pinned that.
Captures the context passed to ranked-results and asserts `:models`
contains "transform" for an admin and never for a non-superuser, with
"dashboard" asserted present in both directions so neither can pass on an
empty set.
Proven to be a real guard by mutation: with `:is-superuser?` hardcoded to
true in the per-query context, the non-superuser assertion failed
(expected (not (contains? models "transform")), actual (not (not true)))
while the admin assertions stayed green.
Deliberately narrower than an end-to-end search: getting a transform to
appear in the index needs search.ingestion/*force-sync* plus an explicit
ingest! built from private search.engine/search.ingestion vars (see
metabase.transforms.search-test), which would couple this test to search
internals. An end-to-end attempt was discarded because its positive
control failed — the admin did not find their own transform — leaving the
non-superuser assertions vacuous. The visibility rule itself is covered by
metabase.search.filter-test; what is new here is the wiring.
Verified: metabot.tools.search-test + mcp.v2.tools.search-test +
mcp.v2.common-test 74 tests / 279 assertions / 0 failures; kondo 0/0;
cljfmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa
* Stop a caller-supplied collection-id escaping a metabot's confinement
An embedded metabot, and the nlq profile, may only search inside its own
collection. That is a containment boundary, not a default. The shared
search fn had been changed to let an explicit collection-id take
precedence over it:
collection-id (or collection-id (when confined? (:collection_id metabot)))
so a caller passing both would search wherever it asked, outside the bot's
collection. Inert today — no caller sets :collection-id, and the v1 tool
schemas are closed — but the v2 search tool fills that key straight from a
request filter, so the escape is one caller away.
Precedence inverted: the confined collection wins, and a caller-supplied
collection-id applies only when the metabot is unconfined.
Test proven red first: confined-collection-is-not-overridable-test failed
on exactly the escape case (expected the bot's collection, got the
caller's) while both controls passed. It pins all three cases — confined
with no collection-id, confined against an explicit one, and an
unconfined metabot still honouring an explicit one, so the fix cannot
over-restrict.
Trade-off: a confined caller asking for a sub-collection inside its
confinement now gets the whole confined subtree instead of the narrower
request. Intersecting would need a descendant check; no caller passes both
today, so it is not built.
Verified: search suites 75 tests / 282 assertions / 0 failures; the other
caller, metabot.agent.core-test, 23 tests / 140 assertions / 0 failures;
kondo 0/0; cljfmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa
* Build narrowing disclosures from the final type set
When `type` is omitted, each of created_by / collection_id / archived can
narrow the search to the types that support it, and each narrowing is
disclosed. Every disclosure built its "narrowed the search to ..." list as
`(set/difference effective-types (set bad))` — the full engine surface
minus only its OWN exclusions — so with two filters narrowing at once the
later message advertised types the earlier one had already removed.
With {created_by: "me", archived: true} and no type, the archived
disclosure claimed the search covered `collection` and `segment` when the
final type set excluded both. The namespace's stated contract is that
narrowing is disclosed; here it was disclosed inaccurately.
The accumulator now records {:excluded :label :because} per filter instead
of a prebuilt sentence, and the sentences are formatted after the fold
from the final narrowed set — one correct type list everywhere, each
message keeping its own reason for what it excluded.
Test proven red first: the archived disclosure failed with
expected: "action, dashboard, document, measure, metric, model, question"
actual: "action, collection, dashboard, ... question, segment"
The created_by disclosure passed, because that filter's exclusions are a
superset and its private subtraction coincided with the final set — which
is why no single-filter test could have caught this.
Single-filter wording is unchanged, so the existing
omitted-type-narrows-and-discloses-filter-incompatibility-test passes as
written.
`:label` rather than `:filter` for the key: as `:filter` it shadowed
clojure.core/filter in the formatting fn. Kondo did not flag it.
Verified: mcp.v2.tools.search-test + mcp.v2.common-test +
metabot.tools.search-test 76 tests / 288 assertions / 0 failures;
kondo 0/0; cljfmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa
* Steer an empty list page instead of returning a bare envelope
truncation-line only fires on arithmetic truncation — (offset + limit) <
total — so a page that came back empty carried no steering line at all.
An offset at or past the end produced
{"data":[],"returned":0,"total":37}
which reads as "nothing matches" when the truth is "nothing *here*", and
gave the caller no hint that paging back would help. The same silence hit
an empty first page whose every match was dropped after the count (a stale
index hit, a row gone unreadable).
list-content now emits an empty-page line whenever data is empty and total
is positive, splitting the two cases: a positive offset is told the offset
and total and steered back with a smaller `offset`; offset 0 is told the
matches are no longer readable or have been removed, and is NOT steered to
an offset, which could not help. A floored total stays "at least N".
Deliberately silent when total is 0 or unknown — that envelope already
says what happened, and inventing a line there would be noise.
truncation-line itself is untouched, so the parameters tool's own
steering-line wrapper (which handles its zero-returned case before ever
reaching truncation-line) is unaffected.
Test proven red first: the five positive assertions failed against the
bare JSON payload, while all three negative controls (total 0, unknown
total, non-empty page) passed before and after.
Verified: common + v2 search + v2 parameters + metabot search 107 tests /
377 assertions / 0 failures; kondo 0/0; cljfmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa
* Disclose the types a collection-scoped search silently drops
The namespace's contract is that the engine's silent narrowing is never
relied on. Two types slipped through it when `type` was omitted and
`collection_id` was set:
transform — its spec has `:collection-id false`, so
search-context->applicable-models drops the model outright from any
collection-scoped search. Always, on every edition.
table — the ::collection-hierarchy where-clause excludes tables when the
Library feature is absent, so OSS silently searched no tables.
Named explicitly, each was already a teaching error. With `type` omitted
both were dropped in silence, and the disclosure the caller did get named
them as in scope: "collection_id narrowed the search to ..., table,
transform — database, measure, segment don't live in collections."
Both are now narrowed and disclosed alongside the collectionless types,
each with its own reason, since they are excluded by different parts of
the engine and only one is edition-dependent.
The transform half was not in the original review note; it surfaced from
the pre-fix disclosure text while fixing the table half.
Tests proven red first: 6 assertions across the two affected deftests.
One pre-existing assertion legitimately changed — the collection_id
sub-test of omitted-type-narrows-and-discloses-filter-incompatibility-test
asserted exactly 1 disclosure and now sees 2. It is also now pinned to
#{:library}, so the count no longer depends on whichever token the suite
happens to run under.
Verified: v2 search + common + parameters + metabot search 108 tests /
386 assertions / 0 failures; kondo 0/0; cljfmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa
* Sign the MCP session capability payload so a client cannot self-assert it
`supports-mcp-ui?` is what `:required-extensions` gates on, and it read a
payload the CLIENT echoes back in its own session id. Unsigned, that is a
self-assertion: any caller could mint `<uuid>.{"v":1,"ui":true}` and claim
MCP Apps support it never advertised at `initialize` — which is what let a
narrow bearer reach `refresh_ui_credential` and mint a UI credential.
Session ids are now `<uuid>.<payload>.<signature>`. The signature covers
the uuid as well as the payload: every UI-capable session carries the
identical payload, so a payload-only signature would be a reusable token
that could be lifted onto any correlator. It is domain-separated from the
UI-credential HMAC so neither can be replayed as the other.
An unsigned or tampered id remains a VALID session. The id authenticates
nothing — it is a stateless correlator, and any well-formed one works for
its presenter, which `well-formed-but-never-issued-session-id-is-accepted-for-its-presenter-test`
pins. Only the claim inside it is disbelieved, so the gate fails closed
without breaking that property.
`supports-mcp-ui?` also stops returning true for a bare UUID. That branch
made the signature pointless — a caller could omit the payload entirely
and be believed anyway. Such ids predate capability hints, so `unknown`
is the honest reading.
Behavioural, and shared with v1: a client holding a session minted before
this change loses MCP Apps tools until it re-initializes. Bounded by
session lifetime, and it fails closed.
* fix(mcp): ignore database id for get_fields
Allow agents to carry database_id through the browse hierarchy without rejecting get_fields requests.\n\nRefs GHY-4396
* Stop stamping the MCP UI credential as an unrestricted session
The credential was stamped `::scope/unrestricted`, so the route allowlist
was the only thing between it and full session privilege. Those are two
different questions — which routes the credential may pass through, and
what privilege it arrives with — and collapsing them into one value meant
any way of reaching a route off the list (a routing change, a new alias,
a mistake in the set) served the request as the user.
It now carries `::scope/mcp-ui`, a second sentinel alongside
`::unrestricted`. It satisfies no endpoint's declared `:scope`, and
`ensure-scopes-checked` refuses it where none is declared, so the same
mistake fails closed. Being a keyword it can never be requested, granted,
or named on a consent screen. `:token-scopes-checked` is what lets the
allowlisted routes serve it, since they declare no scope of their own.
Deliberately NOT done by annotating those routes with the sentinel:
`/api/user/current`, `/api/session/properties` and the `/api/dataset`
routes live in `users-rest`, `session` and `query-processor`, and marking
them would push MCP vocabulary into three unrelated namespaces —
deepening the coupling GHY-4400 exists to remove, to buy locality in a
set currently frozen at eight entries.
The allowlist's docstring now says what it is: an inventory of what the
embedded app happens to call, because the credential is installed as
app-wide auth on the client, rather than a decision about what it should
reach.
* [mcp-v2 12] definitions: pin the table-move test's permission precondition
`table-move-permission-test` passed a solo namespace run but failed CI's
full-suite partition. `with-no-data-perms-for-all-users!` resets only the
All Users group, so the temp group's access to `checkins` was left at
whatever default a freshly created group inherits — ambient state that
differs between a namespace run and a full partition. When the analyst
could author on `checkins`, the retarget succeeded and the assertion
failed as "expected a tool error, got success".
Block the database for the group and grant `venues` back, so `checkins`
is denied by construction rather than by the absence of a grant, and
assert both halves of the precondition up front so a recurrence fails at
the setup with a clear message instead of as a confusing success.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbCo3ybRitdVSQXYcG97QR
* Stop answering JSON-RPC notifications
A message carrying no `id` is a notification (JSON-RPC 2.0 §4.1) and must
not be answered. A known method sent without one — `{"jsonrpc": "2.0",
"method": "ping"}` — executed and then replied with `"id": null`: a
response invented for something the client is not listening on, in the
shape §5 reserves for a message that could not be parsed at all.
Notifications still execute. A notification is a request the client is
not waiting for, not one to ignore, so the dispatch is unchanged and only
the result is dropped. The malformed case keeps its null-id reply, which
is what §5 actually asks for; conflating the two is what produced the
bug.
Keyed on absence of the `id` key rather than a nil value, so a request
that explicitly sends `"id": null` is still treated as malformed rather
than silently swallowed.
* Pin the superuser flag instead of reading it off a shared test user
transform-visibility-is-superuser-only-test failed on the MySQL 9 EE and
MariaDB EE app-db shards, deterministically on both. The failure was the
POSITIVE control: the admin case saw models #{"dashboard"}, so transform
was absent for everyone and the non-superuser assertions were proving
nothing.
Cause: the test took the superuser flag from the ambient test user, and
`mt/with-test-user` resolves it through `request/with-current-user`, which
SELECTs the shared user row:
(t2/select-one [:model/User [:is_superuser :is-superuser?] ...] :id ...)
That makes the assertion depend on the state of a row other tests in the
same parallel shard also touch. This branch has already been bitten by the
same class of pollution once — recents-total-is-a-floor-test, fixed by
moving off the shared :crowberto user.
`api/*is-superuser?*` is now bound explicitly. That is the exact var
`search.filter/visible-to?` reads, and pinning it is what the assertion is
actually about: that this pipeline propagates the caller's superuser
status into the search context, not where the status came from. A sanity
assertion on the admin branch ("dashboard" must be present) keeps a future
empty/short model set from passing as a green positive control.
This isolates the variable rather than hiding it: if transform were being
dropped for some reason other than the superuser flag, the test would
still fail on those shards with the flag pinned true.
Still catches the regression it guards: with `:is-superuser?` hardcoded to
true in the per-query context, the non-superuser assertion fails with
expected (not (contains? models "transform")), actual (not (not true)).
Verified: metabot search + v2 search + v2 common 79 tests / 308 assertions
/ 0 failures; kondo 0/0; cljfmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa
* Run the transform visibility test on both search engines
The previous fix was wrong. transform-visibility-is-superuser-only-test
kept failing on the MySQL 9 EE and MariaDB EE shards after it, with the
positive control still red:
"a superuser's search reaches the engine with transforms in scope"
expected: (contains? models "transform")
actual: (not (contains? #{"dashboard"} "transform"))
The superuser flag was never the cause. Pinning it did isolate the
variable, which is how this became findable: with `:is-superuser?` bound
true the admin case still saw no transform, so the gate had to be
elsewhere.
Two engines gate transforms by different code. MySQL and MariaDB cannot
hold the appdb search index, so they fall back to in-place, whose
applicable-models has a rule the appdb path has no equivalent of:
(empty? enabled-types) (disj "transform")
`enabled-transform-source-types` comes from `transforms-enabled`, whose
getter falls back to a cloud-token check when the setting is unset — so
under `with-premium-features` it was false, the source-type set was empty,
and in-place dropped transform for everyone. H2 uses the appdb path, which
has no such gate, so the test passed locally and was meaningless on the
two shards that ran the other engine.
Both inputs are now pinned: `transforms-enabled` is set explicitly, and
the body runs under `with-appdb-search-and-legacy-search` so the shard's
engine choice can no longer decide whether this test means anything. The
assertion count doubling from 4 to 8 confirms both passes execute.
Diagnosis proven locally rather than by another CI round-trip: with
`transforms-enabled` flipped to false, exactly 1 of 8 assertions fails —
the superuser case, on the in-place pass only — reproducing the CI failure
byte for byte on H2.
Verified: metabot search + v2 search + v2 common 79 tests / 312 assertions
/ 0 failures; kondo 0/0; cljfmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa
* fix(mcp): include dashboard parameter wiring
* Cover the query_handle source, which no live test reached
Both handle tests were deferred behind the unregistered execute_sql tool, so
nothing exercised resolve-source's query_handle branch: a regression there —
notably resolving through the native-rejecting resolve-query-handle! instead of
resolve-query-handle-for-save!, which would make every execute_sql handle
unsaveable — passed the suite untouched.
Mint handles straight into the store instead, the way question_test already
does. The deferred tests stay as the ledger for the whole-tool path; these pin
the branch in the meantime.
Four cases: an MBQL handle creates a transform whose target follows the
handle's database, a native handle stores as a native transform without
re-demanding agent:sql:run (minting it already passed that gate — the
exemption is deliberate, and is the half of the contract the mutation above
would silently break), a handle swaps an existing transform's source, and an
unknown handle teaches the recovery rather than surfacing an internal error.
Verified by mutation: pointing the branch at resolve-query-handle! fails 2 of
the 4; disabling the branch outright fails all 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Lzc5ERUWqWRx9JbDxK5Lc
* Mint a cursor only for a projection with a provable unique key
`tiebreaker-specs` fell back to the full projected tuple whenever no source-table
PK was projected, and treated that tuple as a total order. For an unaggregated
stage it isn't: the tuple repeats, and the strictly-past-the-boundary keyset then
skips every row tied with the boundary.
A `fields`-narrowed projection of non-unique columns hit this. PRODUCTS holds 200
rows across 4 categories; paging a CATEGORY-only projection at row_limit 5 served
20 rows and then reported itself complete — no truncation flag, no cursor, 180
rows silently gone.
Split the two meanings the function conflated. `unique-key-specs` now returns nil
when neither uniqueness proof holds, so a missing proof is distinguishable from an
order that is already total, and both callers refuse instead of inventing one.
The second proof has to look back past the last stage rather than just testing it:
`next-page-query` carries an aggregated query's keyset in an appended stage, so
from page 2 on the last stage is that pass-through — unaggregated and PK-less.
Inspecting only the last stage would close the gap and kill every aggregated chain
at page 2 instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL8iPKC7nSLarfcnYDkaCS
* fix(mcp): address v2 protocol review
Reject unsupported DELETE without dropping session ownership. Enforce JSON-RPC 2.0 envelopes and notification silence. Keep pre-dispatch tool failures on the JSON-RPC error channel.
* Carry the target's database through a source-database swap
The target was only recomputed when the call named one, so an update that
swapped the source onto another database left the stored target pointing at the
old one. The target records the database it writes and that database follows the
query, so the row — and the echo built from it — named a database the transform
does not write. Feeding that echo back then hit resolve-target's target/query
database check and was refused, dead-ending the read-modify-write the tool is
built around. Only the JSON blob was stale; before-update recomputes the
target_db_id column, so runs went to the right place.
Patch the database onto the existing target instead of rebuilding it through
resolve-target: a target this tool can't author (incremental) still gets its
database corrected rather than becoming uneditable by a source-only edit. The
caller's own `target` still wins, being later in the cond->.
The test drives it across a real second database via with-temp-copy-of-db and
was watched failing first: stale database in both echo and stored row, plus the
round-trip rejection as the third symptom.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Lzc5ERUWqWRx9JbDxK5Lc
* Supersede the previous page's keyset instead of stacking beside it
`lib/filter` appends — `add-filter-to-stage` skips only exact duplicates — so
every cursor page ANDed a fresh boundary predicate onto the stage while leaving
its predecessors in place. Paging ORDERS two rows at a time, the stored handle
grew 668 -> 2692 bytes over nine pages and kept going, one dead predicate per
page, in the payload and in the emitted WHERE alike. With the full-tuple key each
of those clauses is quadratic in the projection width.
Stamp each minted clause and drop stamped ones off the target stage before adding
the current page's. Dropping them cannot change results: every row the new
predicate admits lies strictly past a boundary row that itself satisfied the old
one, so the new predicate implies the old. Filters the caller wrote carry no stamp
and are left alone.
The stamp rides in the clause's options map, which no schema declares. It survives
serialization, the base64/JSON round trip through the handle store, and
rehydration today; should Lib ever strip undeclared option keys the predicates
would accumulate again exactly as before — same results, larger query — so this is
an optimization and not something correctness rests on.
Stored handle now flat at 716 bytes across nine pages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL8iPKC7nSLarfcnYDkaCS
* Finish the sentence that teaches the source shapes
`accepted-shapes` is appended to five source-shape teaching errors — it is what
an agent reads to work out how to retry — and it promised "either" and then
listed one dialect. The missing half was the name-based dialect, which
definition->query genuinely resolves via portable-query?, so the message
advertised only one of the two shapes the tool accepts. Restored in the wording
metric.clj already uses for the same pair.
Nothing held the sentence to the code, because only the numeric-id dialect had a
test. Cover the portable branch, and assert the sentence names both dialects so
the half-written form can't come back.
Verified by mutation: disabling the portable branch fails the new
portable-definition test and nothing else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Lzc5ERUWqWRx9JbDxK5Lc
* Refuse tag ids that name no tag, instead of dropping them
update-transform-tags! filters unknown ids out of the list it is given and
writes what's left, so `tag_ids` — documented as "replaces the current list" —
replaced it with a shorter one and reported success. An agent constructing ids
from a stale read has no reason to look twice, and jobs select the transforms
they run by tag, so the tag that failed to attach is noticed when a schedule
doesn't fire. Checked in the tool rather than in the shared write, which REST
also calls: this is the same restatement check-target-free! makes of REST's
target-conflict 403.
Cover collection_id and tag_ids while here — neither had a test, which is why
the silent drop had gone unremarked. The transform-folder namespace rule is
pinned too, since that refusal comes from a layer below this tool.
Adds a :model/TransformTag reference, so the generated module config gains it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Lzc5ERUWqWRx9JbDxK5Lc
* Pin the cursor's reliance on the QP to re-type a stringified boundary
Every execute_query run carries `js-int-to-string?`, which renders integers past
2^53-1 as strings. The cursor boundary is read straight out of the served row, so
a key column holding such values mints a predicate that compares a numeric column
against a string literal — verified, it really does emit
`[">" [field type/BigInteger] "9007199254740995"]`.
That compiles correctly, on every driver, because the query processor re-types it:
`wrap-value-literals` gives the bare literal the column's effective type and
`auto-parse-filter-values` parses it back to a bigint, both above the driver layer
(preprocess.clj:106-107).
Nothing covered that, and it is invisible from the cursor code, which reads a row
value and hands it to `lib/>` with no indication that its type is the query
processor's problem. Add a fixture whose values straddle the boundary and a test
that pages across it, plus a comment naming the dependency, so a change to the
middleware order or to the boundary read fails loudly here rather than in a
warehouse.
No production behavior change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL8iPKC7nSLarfcnYDkaCS
* Mint a handle from the question, not from the page it was read on
A cursor page's stored query embeds the keyset boundary it resumed from — that is
how paging works — and the handle returned alongside was minted straight from it.
So a caller who paged to page 3 and then saved got a card reading "orders WHERE
id > 4171": a scroll position persisted as a question, which is nobody's question.
Strip the minted keyset off the last stage before storing, in `mint-handle!` so
every mint site is covered. Only predicates this namespace stamped come off; a
filter the caller wrote is part of the question and stays. Paging is untouched —
the cursor carries its own boundary — and it is a no-op for a query that never
paged.
Re-running a cursor page's handle now serves the question from the top rather than
that page again. The tool description and the namespace docstring promised
"exactly the query that ran" and are corrected: a handle is what a later tool
saves or visualizes, so the page boundary was never the useful part of it.
One artifact stays: a query carrying its own :limit has it spent down across pages,
so a deep page's handle asks for the rows still owed rather than the count the
caller wrote. Recovering that needs state the handle store deliberately does not
keep; `without-page-boundary` says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL8iPKC7nSLarfcnYDkaCS
* Register the :document projection where both its tools can see it
document.clj projects through :document but did not require the namespace
that registered it, so the registration was reachable only via api.clj's
alphabetical require order. Loading the document tool on its own — from a
test, or any future consumer — left the projection unregistered, and
projections/project throws a 500 that the error sanitizer collapses to a
bare "Internal error".
Move the registration to projections.clj, which both tools already require,
matching the :question block's rule for a projection two tools share. The
key vector goes with it; content.clj's select-keys pre-narrow is dropped
rather than duplicated, since project re-applies select-keys on every path
and ::document retains the full map anyway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5
* Return dimensions the way the metric and measure endpoints do
The `dimensions` include documents itself as returning the same
dimensions/dimension_mappings pair GET /api/metric/:id and
GET /api/measure/:id return. Nothing compared the two, and it drifted three
ways:
- It skipped the wire encoding, so the section shipped the internal
kebab-case shape (display-name, effective-type, and an internal
:lib/source) where every other field in the tool, and the REST API, uses
snake_case REST property names.
- It kept orphaned dimensions, which both endpoints drop unless asked for
them. An agent grouping by one would query a column that no longer exists.
- compute-dimensions reconciled every computed pair for an uninitialized
metric, while seeding takes own-table and explicitly-joined columns only.
A metric nobody has opened in the UI reported every FK-reachable column,
and since this read never persists, it kept reporting them.
Encode through ->api-dimensions/->api-dimension-mappings, drop orphans, and
give compute-dimensions the same three-way metric branch sync-dimensions!
has, sharing one definition of the seed filter with it.
The parity test this needed also replaces the curated-metric fixture, which
was built from get_content's own output and so agreed with whatever shape
the tool emitted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5
* Don't record a document view on a get_content read
`documents/get-document` publishes `:event/document-read` unless told not
to, and that handler bumps view_count, stamps last_viewed_at, writes a
view_log row, and pushes the document onto the caller's recently-viewed
list. get_content declares readOnlyHint and idempotentHint, and the
dimensions include already goes out of its way to compute without
persisting; a document read was quietly doing four writes and filling a
user's recents with the agent's reads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5
* Register the :metric projection once, where both its tools can see it
content.clj and metric.clj each registered :metric with identical key
vectors. register-projection! overwrites silently, so which one won was
decided by api.clj's require order, and the two would have drifted apart the
first time either was edited.
Deleting either copy on its own just moves the problem: whichever tool keeps
the registration leaves the other reading a projection registered by a
namespace it does not require. Register it in projections.clj instead, which
both tools already require — the same place :question and :document live,
and for the same reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5
* Drop the TODO comments above the two restored document tests
Both tests were deferred while get_content did not exist and came back with
this slice; only the notes telling a reader they were still disabled were
left behind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5
* Stop documenting a per-type scope gate that isn't there
The type->spec docstring said each entry carried "the extra runtime :scope
the type needs on top of the tool's base agent:resource:read", and the ns
docstring said each item was scope-checked. No entry has a :scope key, the
handler never reads one, and agent:resource:read is not a scope that exists.
The behaviour is right — GHY-4225 folded the per-entity read scopes into
agent:content:read, and the tool gates on that once at the registry
boundary, the same way search does. Only the comments were wrong, and a
comment describing a security control that isn't implemented is one a reader
will trust. Say what actually happens, and say that the absence of a
per-type scope is deliberate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5
* Cover the read paths get_content had no tests for
Four gaps, all coverage rather than defects — the behaviour was already
correct in each case:
- entity_id resolution had no test at all, though it is half the id schema.
Covers collection, question, snippet and document by entity_id, plus the
two teaching errors: alerts are numeric-only, and a string that is neither
shape is rejected.
- the definition include was only exercised for question. measure, segment
and transform each build theirs behind a catch that returns nil, and the
section is omitted when it does, so a broken exporter was indistinguishable
from a type with nothing to export.
- reads-formerly-gated-types named transforms but tested only alert, snippet
and document, so no transform read ran under a real agent:content:read
token.
- the transform target table had no test.
On that last one: the permission gate's false branch turns out to be
unreachable. Reading a transform requires superuser or data analyst,
superusers read every table, and table-permission-for-user hands every data
analyst manage-table-metadata :yes unconditionally, which alone satisfies
can-read? on a Table. Rather than force it with redefs, the test covers the
reachable paths — present on a detailed read, absent on a concise one, absent
when the target has never been created — and records why the gate is dead
code kept as defence in depth.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5
* Say what the SQL kill switch actually gates, and stop telling callers to pass a cursor alone
Two docs that contradicted the code.
check-execute-sql-enabled! claimed "every v2 path that runs or stores raw SQL
consults this one gate." It does not: run_saved_question executes a stored native
card without it, as does execute_query over a :source-card naming one. The gate
covers the paths where the agent AUTHORS the SQL — execute_sql, and question_write
/ transform_write storing agent-authored native — which is what the setting's own
description scopes it to, and what the card's read check guards on the other side.
Say that instead, so nobody infers the stronger rule and treats those two paths as
holes.
The cursor argument told callers to "pass it alone" while the tool description
told them row_limit alongside keeps the page size — and the cursor test passes
both. The exclusivity it was reaching for is already stated in the same sentence
and covers only query | query_handle | cursor, so an agent obeying the arg would
have silently dropped back to 100-row pages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL8iPKC7nSLarfcnYDkaCS
* Move the v2 redaction queries into mcp/db.clj, guarding the id splice
`redaction.clj` was the one namespace this branch adds that ran its own
app-DB queries, against the policy that a module's queries live in its
`db.clj`. Both move across unchanged in behaviour: the `t2/hydrate` call
becomes `mcp.db/hydrate-notification`, and the batched tenant lookup
becomes `mcp.db/user-id->tenant-id`. `lookup-recipient-tenant-ids` keeps
only the part that is actually redaction logic — deriving the ids of the
recipients whose `:user` hydration came back nil.
The empty-collection case moves with the query rather than staying at the
call site: `[:in ()]` is not valid SQL, and that is a fact about the query,
not about recipients.
`user-id->tenant-id` refuses anything that is not a collection of
integers. The ids are spliced into an `[:in ...]` clause, where HoneySQL
binds a number as a parameter but renders a string, a raw form, or a
nested map as SQL — so a non-integer element arriving there is an
injection vector rather than a type error. It is refused before any SQL is
built, and the offending values ride `ex-data` rather than the message so
nothing caller-controlled reaches a log line.
The check is `int?`, not `Long`: `core_user.id` is a 32-bit column and
comes back from JDBC as an `Integer`, so a Long-only check would satisfy
any test written with literals and then refuse every real call. `int?`
still admits only boxed integer types, which is the whole of what the
guard needs. It throws rather than using `assert`, matching the mint-time
guard in `session.clj` — `*assert*` can be compiled out, and a guard that
disappears in some builds is not a guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FwF5gxSyq2T5FsnyhViozq
* update tools descriptions
* update mcp v2 skills (#81915)
update skills
* Name the right threat in the user-id splice guard
The guard's docstring said HoneySQL renders a string in an `[:in ...]`
clause as SQL. It does not — strings and numbers are both bound as
parameters. The shapes HoneySQL reads as syntax rather than as a value are
vectors (function calls, so `[:raw "1) OR 1=1 --"]` renders verbatim and
`[:inline x]` inlines a literal), maps (subqueries), and keywords and
symbols (identifiers). Those are the injection vectors; a string id is a
plain bug, refused all the same because a silent no-match is a worse answer
than a throw.
The check itself was already correct and is unchanged. Only the reason
given for it was wrong, which matters for a guard people will edit code
against — the docstring is what tells the next reader which shapes have to
stay refused.
Covers `[:inline ...]` and a bare symbol in the refusal test, the two real
shapes the first pass missed.
Deliberately not wrapping the ids in `[:lift ...]` on top of this. It is
unreachable behind an `int?` check, and it would trade the throw for
driver-dependent silence: `[:in [[:lift "1"]]]` binds the string and H2
coerces it, returning a match, where Postgres would not. For this lookup
that is the wrong direction — a recipient whose tenant silently reads as
nil slips past the tenant filter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FwF5gxSyq2T5FsnyhViozq
* mcp-v2.02 bookmark_content (#81246)
* MCP v2 tool: bookmark_content
Adds the v2 bookmark_content tool — per-user favorites, keyed by (type, id).
Extraction: bookmarks/api.clj factors bookmark!/un-bookmark!/bookmarked? out
of the REST endpoints so REST and MCP share them. The POST endpoint now
creates via app-db/select-or-insert! (concurrency-safe: a racing duplicate
returns the existing row instead of tripping the unique constraint); its 400
on already-bookmarked is unchanged. REST tests extended, existing pass.
Unlike REST, both tool directions are idempotent (see ns docstring for why),
both read-check the item, and the name echo is gated on the read scope so a
write-only token can't use it as a name oracle.
Tests: 9 tool tests + bookmarks REST suite green.
Review question for this PR: one tool — is bookmark_content's contract right?
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
Also lands (first consumer, per the common.clj slimming in the core PR):
- NEW v2/resolve.clj — entity-id / numeric-id resolution + the read-check
pairing with the not-found collapse (+ resolve_test, moved from common_test).
- NEW v2/write.clj — readback (the scope-gated write echo).
CI guard: this slice's mcp -> bookmarks edge put `bookmarks` into the
modules-that-trigger-driver-tests set (mage's module-graph ratchet, 44 -> 45).
Bookmark logic cannot affect driver behavior — the edge is a consumer edge —
so `bookmarks` joins driver-affecting-overrides rather than bumping the
ratchet. Full mage suite green (82 tests).
Review nit (round 1): fetch-item's flavor-mismatch teaching error printed
"is a null" for a card type outside question/model/metric; guard the nil case
with a type-agnostic message.
--- Round-2 /review-pr response ---
IMPORTANT (per-user-test could not fail on its named assertion) FIXED. The test called the tool
bare, so a tool-level error was discarded and the assertions passed regardless. It now routes
both calls through tool-result (throws on tool error) and has :lucky bookmark-then-un-bookmark
its own row, so the "one user's bookmark is invisible to another's un-bookmark" claim has a live
counterfactual. Proven fail-when-broken (stubbing un-bookmark! to a no-op turns the test red).
MINOR fixed: the id schema now requires a positive int (matching REST's ms/PositiveInt); the
degraded write-scope readback now surfaces :bookmarked (via a new backward-compatible ack-keys
arg on v2.write/readback, so a write-only caller still learns the resulting state).
MINOR deferred (pre-existing REST behavior, out of a tool-slice's scope, confirmed structurally):
archived content is bookmarkable but filtered from listings; the Trash collection is bookmarkable;
readback's :url key has no producer on this branch (lands with collection_write). Each is shared
with the existing REST bookmark endpoints; changing it means deciding REST semantics.
Fresh-JVM: bookmark-test 9 tests / 43 assertions / 0 failures; kondo 0/0; modules unchanged.
* Route resolve.clj through mcp.db; correct the db.clj docstring
Three fixes from review of the mcpv2-01-core merge resolution.
resolve.clj called t2/select-one directly, so the toucan2 kondo rule this
same change adds shipped already violated by a file it introduces -- and at
the highest-value site, where an agent-supplied id enters a query. Adds
mcp.db/select-one-by-id, which asserts the id is already an integer (entity
ids are resolved before they reach it, so a string there is a bug rather
than a lookup), and routes resolve.clj through it. The rule now flags only
v2/redaction.clj, which predates this branch.
bookmarks/db.clj's ns docstring still claimed every function was "a direct
Toucan 2 call with no additional logic" after the generic dispatch tier
moved in, which is the one part of that docstring a reader relies on.
insert-bookmark!'s unique-constraint rationale moves from its docstring to
an inline comment on the select-or-insert! call it explains.
Tests: bookmarks.api-test + mcp.v2.tools.bookmark-test + mcp.v2.resolve-test,
30 tests / 150 assertions, green.
* readback: refuse an empty read-scopes instead of skipping the gate
`(remove f [])` and `(remove f nil)` both yield `()`, so an empty or nil
`read-scopes` made `missing` empty and returned the full ungated row -- a
gate that did not run was indistinguishable from one that ran and passed.
Not reachable from `bookmark_content`, which passes a literal one-element
vector. It matters because this namespace is shared write-path machinery for
the `_write` tools still to land, and the mistakes a future caller actually
makes -- omitting the argument, or computing `read-scopes` from a per-type
map that misses -- both landed on silently ungated rather than an error.
There is no legitimate empty case, so it throws.
Adds write_test.clj, which the namespace did not have: full-row grant,
degradation (exact key set, so `:name` cannot leak), empty and nil
`read-scopes` refusal, the unrestricted sentinel and nil `token-scopes`,
note pluralization, and an ack-key absent from the row. Verified the
refusal test bites -- removing the guard fails it twice (`[]` and nil) and
leaves the other five green.
Also drops `readback`'s 3-arity, which had no callers, and the ns
docstring's reference to which slice lands next.
Tests: write-test + tools.bookmark-test + resolve-test + bookmarks.api-test,
36 tests / 161 assertions, green.
* Clean up comments in resolve.clj
Removed unnecessary comments for clarity.
* Drop the stray toucan-in-mcp kondo rule from this branch
The rule entered here through the mcpv2-01-core merge resolution rather than
from either parent, and it now lives on mcpv2-00b-shared-infra next to the
mcp/db.clj boundary it governs. Keeping a second copy here only produced seven
false positives on MCP test namespaces, whose toucan2.core requires are fixture
setup rather than the agent-id boundary the rule protects.
Every src/metabase/mcp namespace that touches toucan2.core is already exempt
(db.clj and models.*), so the rule guards nothing on this branch. It comes back
with the shared infra when the stack restacks.
* Fold the dead per-model inserts into one unknown-model failure
The generic (model, id, user-id) tier left three sibling functions dispatching
three different ways. insert-bookmark! reaches the bookmark models through a map
and select-or-insert!, so the five per-model insert fns it replaced had no
callers left anywhere; delete them.
An unrecognized model string failed differently in each of the three: a case
with no default threw "No matching clause", while insert-bookmark! destructured
a missing key into [nil nil] and failed further down inside Toucan with a nil
modelable. Route all three through one ex-info. Nothing reaches this today --
both callers validate against a schema first -- but this branch promotes
metabase.bookmarks.db to module API, so callers that validate nothing else can
now arrive.
Also guard the REST contract the insert swap sits under: a duplicate POST to
/api/bookmark/:model/:id still answers 400, which nothing covered before.
---------
Co-authored-by: Eric Normand <ericwnormand@gmail.com>
* mcp-v2.00b: land common.clj at its final size in 01 (#81975)
* Extract the v2 shared machinery into 01
The decomposition rule was "shared machinery lands with its first consumer."
The cost is that the first consumer becomes a dependency for every slice
after it, so a slice needing two functions inherits everything beneath the
slice that happened to add them.
`get_content` is the worked example: it calls `common/response-format` and
`common/select-fields`, both of which first appear in 08-search, so it sits
behind eight slices to reach two functions. Slices 03, 06 and 07 contribute
nothing it uses -- 03 adds zero defs to common.clj, 07 zero, 06 one. They
are in its path because 08 was stacked on them chronologically.
Brings the three shared namespaces to their final size here instead:
- common.clj 172 -> 342: the fields/response-format machinery (with its
private helpers) and the list envelope. `select-fields` needs
`projections/catalog`, which resolves from a runtime registry and whose
namespace already ships in 01; `LevenshteinDistance` is already on the
classpath via metabot.
- resolve.clj (118) and write.clj (85), which 02 created and 03 finalised.
Taken at their reviewed state, including routing the read through
`mcp.db/select-one-by-id` and refusing an empty `read-scopes` in
`readback` rather than silently skipping the gate.
- mcp/db.clj gains `select-one-by-id`, which resolve.clj needs.
Module config regenerated: `channel` (list envelope urls) and
`eid-translation` (id resolution) join the mcp module's :uses.
Slices 02, 03, 06, 07 and 08 still define this code and will conflict with
identical content when they merge; each needs the duplicate dropped. In
exchange 16 of the 21 slices stop depending on each other and can be
reviewed in any order.
Tests: v2 common/registry/api/redaction + transport, 75 tests /
445 assertions, green.
* Move the toucan-in-mcp kondo rule here, with the code it governs
The rule discourages toucan2.core outside metabase.mcp.db, and this branch is
what introduces resolve.clj routing its read through mcp.db/select-one-by-id.
Landing the rule with the slice that first followed it left a window where the
convention held in code but nothing enforced it.
Exemptions unchanged: metabase.mcp.db is the seam itself, and
metabase.mcp.models.* define the models it sits on top of. Warning rather than
error while v2/redaction.clj still violates it.
* Bring resolve_test and write_test along with their namespaces
This branch was shipping resolve.clj and write.clj with no tests -- including
readback's empty-read-scopes guard, which exists precisely because that
function had none and its fail-open path went unnoticed.
Tests: resolve-test + write-test + common-test, 17 tests / 51 assertions.
* Scope the toucan rule to source, and land the redaction fix with it
CI failed on six warnings, all in test namespaces. The pattern matched
namespace names beginning `metabase.mcp.`, which includes
`metabase.mcp.api-test` and its siblings. Tests call Toucan directly on
purpose -- fixtures, row assertions, counts -- and none of that is an
agent-supplied trust boundary, so the rule's reason for existing does not
apply to them. Excluded using the same `-test`/`test-util` convention the
file's own `test-namespaces` group uses rather than inventing another.
That left one real violation, v2/redaction.clj, and the fix for it already
existed on 02 (8791186195: the redaction queries moved into mcp/db.clj
behind an id-splice guard, with db_test.clj). Cherry-picked here, since a
rule that arrives already-violated teaches the next reader that violating it
is normal.
clj-kondo over src/metabase/mcp and test/metabase/mcp: 0 errors, 0 warnings.
Tests: db-test + v2 redaction/resolve/write/common, 25 tests / 82 assertions.
* Drop the toucan-in-mcp kondo rule; it belongs in the closing PR
Landing the rule at the bottom of the stack means every later slice that
calls Toucan directly fails the Clj-Kondo job when it merges into 01 --
warnings are fatal there. That is ~13 slices, each adding one new
tools/X.clj with a median of 2 t2/ call-sites.
The rule's value is stopping *new* violations, and during the merge window
nobody is writing new MCP namespaces; the code is already written. Enforcing
now converts clean merges into unscheduled refactors.
The redaction fix cherry-picked alongside it stays -- routing those queries
through mcp/db.clj behind an id-splice guard is worth having regardless of
whether a linter enforces the pattern.
Rule text and its exemptions are recorded on GHY-4416 for the closing PR,
which lands it after the burn-down with :level :error rather than :warning.
* Land common.clj's tests alongside the functions
The hoist moved the functions into 01 without their tests, leaving
select-fields, response-format, frontend-url and the list envelope with
no CI signal until slice 08 lands.
Cherry-picks the five deftests and the fields-test projection fixture
from mcpv2-08-tool-search; common_test.clj is now byte-identical to that
branch's version. 10 tests, 67 assertions, 0 failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156QMhf6CLY92wtY3AfKepV
* Close three coverage blind spots in common_test
truncation-line had no direct test at all — it was only ever reached
through list-content, which never passes :param or :total-floor?. Both
are on the path parameters.clj takes on later slices, so the new
truncation-line-test pins the narrow-with sentence and the "at least N"
floor rendering, plus the two nil cases.
The unknown-path assertion in select-fields-test matched only
#"Unknown field path.*Nearest", so any three catalog entries would have
passed and the Levenshtein ranking — the sole use of the
LevenshteinDistance import — went unasserted. It now pins the leading
two suggestions ("nmae" -> name, collection); the third slot is left
open because collection_path and parameters.name are close enough that
pinning it would test the tie-break, not the ranking.
paths->tree's reduce across distinct top-level keys was unexercised —
the existing merge cases both walk one branch. Added the ordinary
fields: ["name","parameters.type"] shape.
Tests for the three bugs filed against this code (GHY-4420 the
total-floor? boundary, GHY-4421, GHY-4422) are deliberately left out:
they would assert today's buggy behavior, and each belongs with its
fix. The floor case here stays strictly truncated for that reason.
11 tests, 72 assertions, 0 failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156QMhf6CLY92wtY3AfKepV
* Restore list-content's :empty-hint option ahead of its callers
The hoisted list-content computes its own empty-page sentence and takes
no :empty-hint. Six branches further up the stack (10-duplicate,
16-browse, 19-visualize, 20-oauth, 21-retire-v1, dashboard-write) carry
the design-C list-content that does, and the one caller is
list_databases in tools/browse.clj, which explains that browsing needs
query-builder or table-metadata permission on some database. Restacking
those onto this branch would hand that :empty-hint to a function that
ignores unknown map keys: no compile error, no failing test, the
sentence just disappears. Accepting the option here means the slices
land on a list-content that already honors it.
The two sentences are kept mutually exclusive by construction rather
than by coincidence of the current call site:
(or (empty-page-line opts) (when (= 0 total) empty-hint))
empty-page-line says "you paged past the end, N exist, page back";
:empty-hint says "there are genuinely zero results, and here is why".
Gating on (= 0 total) is not redundant with the or — without it, a
static hint could print over a paging situation and tell someone with
37 databases that none are visible to them.
The new list-content-empty-hint-test exists so that an incorrect
conflict resolution fails loudly instead of silently. It asserts the
verbatim browse hint text at total 0 (red if :empty-hint support is
dropped again), and that the hint does not displace the empty-page line
at total 37 / offset 100, nor the truncation line on a non-empty page
(red if the guard is "simplified" to a bare or). Both testing strings
name the browse.clj restack they guard.
12 tests, 82 assertions, 0 failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156QMhf6CLY92wtY3AfKepV
* Gate list-content's :empty-hint on the offset, not just the total
`:empty-hint` is documented as the domain reason a genuinely empty result
set is empty, and the gate was meant to keep it away from a caller who
merely paged past the end. It only checked the total, so it did not.
`empty-page-line` requires `(pos? total)`, so at a total of 0 it declines
for every offset — including a nonzero one. The `or` then fell through to
the hint, and `list_databases` (which forwards the caller's raw offset
through `paged-list-content`) would answer "No databases are visible to
you" for someone who had actually paged past the end of an empty list.
The sentence stays true at a zero total, so this is a contract violation
rather than a false statement, but it is not what the comment claims.
Add the offset to the gate and pin it with a regression test. Verified
the test fails against the previous gate and passes against this one.
---------
Co-authored-by: Eric Normand <ericwnormand@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* mcp-v2.09a prep: extract the documents Markdown round-trip layer (#81432)
* MCP v2 prep: extract the documents Markdown round-trip layer
Part 9a of the MCP v2 decomposition of #77844. This lands the documents
Markdown layer that the document_write tool (a later slice, 9b) is the first
consumer of, split off so it reviews as a serialization extraction with no MCP
surface. v1 and v2 are untouched; this slice adds no tool.
- NEW metabase.documents.markdown (+ its 929-line test): the CommonMark <->
ProseMirror AST boundary — parse/serialize/splice, {% card %}/{% entity %}
token scanning, escaping, a nesting-depth bound. Ported verbatim (byte-
identical to the source), so its injection / permission-leak / stack-overflow
hardening is preserved intact.
- metabase.documents.models.document: gains the write orchestration
(create-document!, update-document!, get-document, clone-cards-in-document!
and their card-creation helpers). The unrelated explorations content-gating
block (can-read?/can-write? overrides, the content-visibility cache) is
deliberately NOT ported — it is a different feature's drift in the same file
and would change document permission semantics. Two further exploration-
Summary threads (a stored_result_use.card_id column from an unlanded
migration; serdes snapshot-embed hardening) are likewise excluded.
- metabase.documents.api.document: card-creation logic moves out of the REST
layer into the model (519 -> 358 lines); the endpoints become thin wrappers.
The unchanged REST document api-test is the check (88 tests / 518 assertions).
- metabase.documents.prose-mirror / documents.core: prose-mirror gains
smart-link-model->db-model; the facade exports the new parse/serialize/splice
+ model fns.
- metabase.comments.core / comments.models.comment: the comment facade gains
child-target-ids-for-document / comments-for-document / register-context-gate!
(notify-comment-id! kept — a grep confirmed zero callers, and dropping a
public re-export is a one-way door).
Fresh-JVM: markdown-test 285 assertions; REST document api-test 518; :module
comments 154; all 0 failures. kondo 0/0; cljfmt clean; modules-config regenerated
(documents module gains :model/Database). The one :module documents error
(collection_test Duplicate key) is a pre-existing deterministic bug in a file
this slice does not touch, reproducing identically in isolation.
* Address review findings in the documents markdown layer
- splice gets the same StackOverflowError -> 400 backstop parse has: the
re-parse of the edited region runs through flexmark, whose inline parser
can blow the stack on a long delimiter run in the replacement text.
- A codeBlock :language holding a backtick (which a tilde fence's info
string legitimately parses into) now re-serializes behind a tilde fence
instead of silently breaking the fence line, which let fenced
{% card %} text re-parse as a real embed; a newline in :language
collapses to a space.
- inline-lines collects per-line chunks and joins once: appending to the
accumulated string was quadratic in a paragraph's inline node count
(40k nodes: 1.9s -> 156ms, and it scaled 4x per doubling before).
- attr-pos-long / format-number range-check before (long n), so an
integral double past long range is a teaching error, not a raw
IllegalArgumentException.
- update-document!'s :cards schema matches the REST DocumentUpdateOptions
it backs (:int keys, ignored without :document) instead of narrowing it.
- comments-for-document keeps a deleted parent that still has replies,
content scrubbed to {}, matching GET /api/comment.
- create-card! back to private (no caller outside the namespace); stale
smart-link docstrings and a broken namespace link cleaned up.
- Tests for all of the above, plus previously uncovered branches:
container content-model violations (incl. via splice), splice span
bounds and the insertion/separator-only arms, token-grammar teaching
errors, and the entity-token code-mark/unknown-model guards.
* mcp-v2.03 collection_write (#81247)
* MCP v2 tool: bookmark_content
Adds the v2 bookmark_content tool — per-user favorites, keyed by (type, id).
Extraction: bookmarks/api.clj factors bookmark!/un-bookmark!/bookmarked? out
of the REST endpoints so REST and MCP share them. The POST endpoint now
creates via app-db/select-or-insert! (concurrency-safe: a racing duplicate
returns the existing row instead of tripping the unique constraint); its 400
on already-bookmarked is unchanged. REST tests extended, existing pass.
Unlike REST, both tool directions are idempotent (see ns docstring for why),
both read-check the item, and the name echo is gated on the read scope so a
write-only token can't use it as a name oracle.
Tests: 9 tool tests + bookmarks REST suite green.
Review question for this PR: one tool — is bookmark_content's contract right?
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
Also lands (first consumer, per the common.clj slimming in the core PR):
- NEW v2/resolve.clj — entity-id / numeric-id resolution + the read-check
pairing with the not-found collapse (+ resolve_test, moved from common_test).
- NEW v2/write.clj — readback (the scope-gated write echo).
CI guard: this slice's mcp -> bookmarks edge put `bookmarks` into the
modules-that-trigger-driver-tests set (mage's module-graph ratchet, 44 -> 45).
Bookmark logic cannot affect driver behavior — the edge is a consumer edge —
so `bookmarks` joins driver-affecting-overrides rather than bumping the
ratchet. Full mage suite green (82 tests).
Review nit (round 1): fetch-item's flavor-mismatch teaching error printed
"is a null" for a card type outside question/model/metric; guard the nil case
with a type-agnostic message.
--- Round-2 /review-pr response ---
IMPORTANT (per-user-test could not fail on its named assertion) FIXED. The test called the tool
bare, so a tool-level error was discarded and the assertions passed regardless. It now routes
both calls through tool-result (throws on tool error) and has :lucky bookmark-then-un-bookmark
its own row, so the "one user's bookmark is invisible to another's un-bookmark" claim has a live
counterfactual. Proven fail-when-broken (stubbing un-bookmark! to a no-op turns the test red).
MINOR fixed: the id schema now requires a positive int (matching REST's ms/PositiveInt); the
degraded write-scope readback now surfaces :bookmarked (via a new backward-compatible ack-keys
arg on v2.write/readback, so a write-only caller still learns the resulting state).
MINOR deferred (pre-existing REST behavior, out of a tool-slice's scope, confirmed structurally):
archived content is bookmarkable but filtered from listings; the Trash collection is bookmarkable;
readback's :url key has no producer on this branch (lands with collection_write). Each is shared
with the existing REST bookmark endpoints; changing it means deciding REST semantics.
Fresh-JVM: bookmark-test 9 tests / 43 assertions / 0 failures; kondo 0/0; modules unchanged.
* MCP v2 tool: collection_write
Adds the v2 collection_write tool — create/update/move/archive collections.
Extraction: collections/update.clj lifts update-collection! out of
PUT /api/collection/:id — the four privates (move-collection!,
archive-collection!, move-or-archive-collection-if-needed!,
maybe-send-archived-notifications!) move verbatim (docstrings de-REST-ified,
notification alias renamed); the endpoint becomes a thin delegator through
the collections.core facade, mirroring the existing create-collection!
pattern. Verified move: bodies diff clean modulo whitespace/docstrings.
The foundation's REST-test changes for this file are all the BROWSE
extraction's repoints, so master's collections_rest api-test runs UNCHANGED
against this branch — that unchanged suite is the extraction check
(fresh-JVM: 202 tests, 941 assertions, green, incl. 23 tool tests).
Deferred to the content tool PR: echo-matches-concise-read-test (the write
echo vs get_content cross-check) — it requires tools/content.
Review question for this PR: one tool — is collection_write's contract right?
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
Also lands (first consumer, per the common.clj slimming in the core PR):
- v2/resolve.clj grows collection resolution (resolve-collection-id{,-or-personal}).
- v2/write.clj grows `_write` method dispatch (dispatch-write + expand-clear).
- common.clj grows frontend-url. Tests moved alongside (resolve_test, write_test).
Review nits (round 1): fix the self-contradictory `name` field description
("Create only (editable on update)" -> "Required on create; editable on
update"); drop structuredContent from the write echo to match the stack's
text-only convention (no concrete programmatic consumer, no outputSchema).
CI kondo fix: the update-collection! extraction moved the only `tru` caller in
collections_rest/api.clj into collections/update.clj, leaving a dead
`[metabase.util.i18n :refer [tru]]` require — an unused-require warning, which
`mage kondo` (CI's check) exits nonzero on. Removed it.
--- Round-2 /review-pr response ---
IMPORTANT (archived:true + parent_id silently discarded the move) FIXED. The dispatch tested
archived first and returned, so a caller passing both got a trashed collection and no move, with
no signal — defeating the tool's "never believes an ignored field took effect" invariant. It is
now a teaching error naming the fix (move first then archive, or pass parent_id on a later
archived:false restore). Regression test added.
MINOR fixed: the four (is (tool-error ...)) assertions now match the specific expected message,
not any error text; the :name destructure was renamed (coll-name) so it no longer shadows
clojure.core/name.
Fresh-JVM: collection-test 24 tests / 65 assertions / 0 failures; kondo 0/0; modules unchanged.
--- Close-out M5 ---
MINOR (bare tool-error assertion): authority-level-update-test now asserts the specific "paid feature" message (parity with the create-side twin), so a wrong error text fails.
* Route resolve.clj through mcp.db; correct the db.clj docstring
Three fixes from review of the mcpv2-01-core merge resolution.
resolve.clj called t2/select-one directly, so the toucan2 kondo rule this
same change adds shipped already violated by a file it introduces -- and at
the highest-value site, where an agent-supplied id enters a query. Adds
mcp.db/select-one-by-id, which asserts the id is already an integer (entity
ids are resolved before they reach it, so a string there is a bug rather
than a lookup), and routes resolve.clj through it. The rule now flags only
v2/redaction.clj, which predates this branch.
bookmarks/db.clj's ns docstring still claimed every function was "a direct
Toucan 2 call with no additional logic" after the generic dispatch tier
moved in, which is the one part of that docstring a reader relies on.
insert-bookmark!'s unique-constraint rationale moves from its docstring to
an inline comment on the select-or-insert! call it explains.
Tests: bookmarks.api-test + mcp.v2.tools.bookmark-test + mcp.v2.resolve-test,
30 tests / 150 assertions, green.
* readback: refuse an empty read-scopes instead of skipping the gate
`(remove f [])` and `(remove f nil)` both yield `()`, so an empty or nil
`read-scopes` made `missing` empty and returned the full ungated row -- a
gate that did not run was indistinguishable from one that ran and passed.
Not reachable from `bookmark_content`, which passes a literal one-element
vector. It matters because this namespace is shared write-path machinery for
the `_write` tools still to land, and the mistakes a future caller actually
makes -- omitting the argument, or computing `read-scopes` from a per-type
map that misses -- both landed on silently ungated rather than an error.
There is no legitimate empty case, so it throws.
Adds write_test.clj, which the namespace did not have: full-row grant,
degradation (exact key set, so `:name` cannot leak), empty and nil
`read-scopes` refusal, the unrestricted sentinel and nil `token-scopes`,
note pluralization, and an ack-key absent from the row. Verified the
refusal test bites -- removing the guard fails it twice (`[]` and nil) and
leaves the other five green.
Also drops `readback`'s 3-arity, which had no callers, and the ns
docstring's reference to which slice lands next.
Tests: write-test + tools.bookmark-test + resolve-test + bookmarks.api-test,
36 tests / 161 assertions, green.
* Clean up comments in resolve.clj
Removed unnecessary comments for clarity.
* Drop the stray toucan-in-mcp kondo rule from this branch
The rule entered here through the mcpv2-01-core merge resolution rather than
from either parent, and it now lives on mcpv2-00b-shared-infra next to the
mcp/db.clj boundary it governs. Keeping a second copy here only produced seven
false positives on MCP test namespaces, whose toucan2.core requires are fixture
setup rather than the agent-id boundary the rule protects.
Every src/metabase/mcp namespace that touches toucan2.core is already exempt
(db.clj and models.*), so the rule guards nothing on this branch. It comes back
with the shared infra when the stack restacks.
* Route collections/update.clj's app-DB queries through collections/db.clj
The collections module keeps its app-DB queries in db.clj; update.clj still
reached for toucan2 directly. `update!` and the final re-read map onto the
existing `update-collection!` and `collection`; the archived-card sweep and the
move's parent lookup needed new fns, mirroring the query shapes
collections-rest/db.clj settled on.
`t2/hydrate` stays in update.clj — it hydrates the value api/write-check
returns rather than fetching one, matching how measures, segments, and
dashboards handle hydrate after the same refactor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZzZhTfVjp6ZGe9fBfK997
* Collapse parent_id's not-found and no-access answers
`resolve-collection-id` probed existence with no read check and let the
downstream write-check throw, so a token holding only agent:content:write could
tell a nonexistent collection id ("not found") from one it merely cannot write
("You don't have permissions"), and enumerate collection ids across the
permission boundary. POST /api/collection answers 403 either way. The `id`
argument already collapsed both through `resolve-and-read`; `parent_id` now
takes the same path, which keeps the bogus-id guard the probe was there for.
Two existing perms tests asserted the old behavior by pointing at another
user's personal collection; they now use a readable-but-not-writable collection,
so they still prove the write check fires without depending on the leak. The
unreadable-parent case is what the new test covers.
Also repairs this namespace's tool-result/tool-error helpers, stale since
c521e4c71c changed call-tool to return {:result}/{:error}. That commit updated
bookmark_test.clj but reached collection_test.clj through a merge with no
textual conflict, leaving every error assertion in the namespace failing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZzZhTfVjp6ZGe9fBfK997
* Drop two collections-rest db helpers left without callers
`cards-in-collection` and `collection-location-columns` each had exactly one
caller, inside the two private fns this branch moved to
`metabase.collections.update`. The collections module cannot require
collections-rest, so update.clj carries its own copies; these are dead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZzZhTfVjp6ZGe9fBfK997
* Refuse a parent_id move that would leave a collection in the trash
Archiving sets only archived/archived_directly/archive_operation_id, so a
trashed collection keeps its pre-trash :location. An update passing parent_id
without archived therefore skipped the archive branch, moved the collection
against that stale location, and echoed the new parent while the collection sat
in the trash — nothing appeared under the new parent.
`archived: true` + parent_id was already rejected; this is the same
contradiction from the other side, so it gets the same teaching error, pointing
at the `archived: false` + parent_id restore path that already works.
The check needs the collection's current state, so it can't join the pure
arg checks in check-method-args!. It takes the collection resolve-existing
already returned rather than fetching its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZzZhTfVjp6ZGe9fBfK997
* Pin the write-echo degradation and the collection-id not-found collapse
Both properties were load-bearing and unasserted. The scope-gating test drove a
write-only token but checked only that :id was an int, which holds whether or
not the readback gate runs — dropping the gate, or widening its ack-keys to
leak :name, failed nothing. resolve-collection-id's main branch had no coverage
at all; the file tested only the nil/"root"/"trash" sentinels.
Each test was verified by mutation: removing the readback call reddens the
degradation test on both methods, and reverting the id collapse to
resolve-id-or-404 reddens the not-found test on the message-equality assertion
that is the actual security property.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZzZhTfVjp6ZGe9fBfK997
* Close collection_write's namespace argument to the one namespace it documents
Any non-blank string was accepted and passed straight through to the create.
assert-valid-namespace only checks a collection against its parent, so a
root-level create with a typo passed: namespace "snippet" produced a collection
in a hierarchy nothing surfaces, and the echo confirmed the typo back — the one
thing the tool's contract says must never happen.
[:enum "snippets"] mirrors authority_level's [:enum "official"] two entries
below, and puts the allowed value in the tool schema where most clients will
reject a typo before it reaches us. transforms is left out deliberately: those
collections are created by the transforms machinery and carry its lifecycle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZzZhTfVjp6ZGe9fBfK997
---------
Co-authored-by: Eric Normand <ericwnormand@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* [mcp-v2 09b] document_write tool
Second half of split slice 09 (DEC-0001). 09a landed the documents Markdown
round-trip layer; this adds the thin MCP tool that drives it.
Review question: does the write tool enforce document-level write permission
before it reaches the docstring-only-guarded model fns, and does it refuse to
launder a foreign card into a document the caller cannot write?
Surface:
- src/metabase/mcp/v2/tools/document.clj — the document_write handler.
create! runs api/create-check before documents/create-document!.
update! runs read-check (resolve-and-read-with) -> check-not-archived ->
api/write-check -> validate-collection-move-permissions, all before
documents/update-document!. Card-embed and smart-link resolution are
permission-checked (mi/can-read?); a foreign readable card is cloned, never
referenced; replace_all is bounded by max-replace-all-work.
- :document projection registered here (first mcp consumer); content/11 drops
its duplicate when it lands.
Inherited 09a-review finding (closed): the documents.core model fns enforce
permission by docstring only. 09b is their first direct caller, so the tool
carries the checks. permission-denied-write-leaves-no-db-trace-test asserts DB
state (no Document row, no cloned Card) on a refused write, not just :isError;
verified fails-when-broken by removing api/write-check.
Deferred tests (restore with slice-11/content): create-clones-card-test,
write-echo-and-read-name-the-body-alike-test.
Gates: document-test 80 assertions, :module mcp 5280 assertions, kondo 0,
cljfmt clean, modules config regenerated (mcp :uses comments+documents,
comments :api gains comments.core).
--- Correctness fix (round-1 review, critical) ---
apply-edit/replace-all spliced new_str into Markdown source as-is. splice re-parses the
edited region, so a replacement like "*" or a leading "#" reopened the block as a list or
heading and could drop a character (the "*" consumed as a list marker) — a text-only edit
silently corrupting structure and losing data. Reproduced: body "x x x x x" + edit x->*
replace_all yielded a bulletList with "* * * *" (4 not 5).
Fix: new public documents/escape-text (markdown.clj) escapes a raw string to its literal
form for any position — inline emphasis/link/code chars per line, plus a per-line leading
block-start escape. apply-edit escapes new_str before splicing; over-escaping a char not in
a special position is inert (CommonMark renders \# as #). splice itself is unchanged (its
markdown-source contract is load-bearing for heading/card-embed inserts — verified 09a
markdown-test stays green). New edit-replacement-is-literal-text-not-markdown-test locks it,
proven fails-when-broken.
* Resolve user smart links under the mention picker's visibility rule
`smart-link-readable?` let any non-sandboxed caller resolve any `:model/User` row, and the stored
label falls back to `:common_name` — the email address when a user has no name. A document author
could therefore enumerate names and emails across tenants, deactivated accounts and API-key
accounts included, by writing `{% entity model="user" %}` tokens and reading the labels back.
User rows are now selected the way `GET /api/comment/mentions` and `GET /api/user/recipients`
select them: a superuser sees everyone; anyone else sees active personal accounts in their own
tenant, narrowed by the `user-visibility` setting. Tests cover a deactivated user and
`user-visibility :none`. Modules config regenerated (mcp now uses users).
* Don't escape an edit that lands inside code
`apply-edit` escaped `new_str` unconditionally so a replacement like `*` or a leading `#` could not
reopen the block as a list or heading. That reasoning holds in prose and inverts inside code:
backslashes are literal in a code span or fenced block, so the escaping stored characters the caller
never wrote. `{old_str "ls", new_str "my_var"}` against "run `ls -la` now" stored
"run `my\_var -la` now" — verified by driving apply-edit before the fix. Snake_case identifiers in
code are the common case.
Escaping is now decided per match: `in-code-context?` counts unescaped backticks and fence lines
before the offset, and `replace-all` prices each occurrence against the serialization it is splicing,
so one match in prose and another in a code span each round-trip correctly.
Claude-Session: https://claude.ai/code/session_018PNCQxp9kCypqE3dLQS9va
* Enable the feature the user-visibility assertion depends on
The smart-link test narrowed `user-visibility` to `:none` and asserted that another user's name stops
resolving. But `user-visibility` is gated on `:email-restrict-recipients`, so in an OSS build the
setting reads `:all` whatever is stored: the narrowing never happened and the assertion pinned the
default instead of the rule. It passed locally and failed every OSS App DB CI job.
Wrapped in `mt/with-premium-features`, matching how the other `user-visibility` tests set it up.
Claude-Session: https://claude.ai/code/session_018PNCQxp9kCypqE3dLQS9va
* Fix document_write Markdown table handling
Reject GFM tables in full document bodies and edit replacements before persistence. Advertise the limitation in the tool description.
* fix(mcp): parse document edits as Markdown
* Budget document_write's edit work per call, not per edit
`max-replace-all-work` was checked once inside `replace-all`, so every entry
in `edits` got its own fresh allowance. `edits` is an unbounded list, which
makes that no bound at all: 100 edits each priced just under the ceiling buy
100x the work in one request.
The ceiling now covers the call. `apply-edit` takes and returns
`{:ast :spent}`, pricing each edit against the total the earlier ones have
already spent. It has to be spent down incrementally rather than totalled up
front — an edit's match count is only knowable against the text its
predecessors produced.
Plain edits are priced too. A single splice still re-serializes the whole
document, so `edit-work` counts splices (one for an edit, one per occurrence
for a `replace_all`) and both kinds draw on the same budget.
`replace-all` no longer serializes to price itself. It takes the
serialization and matches `apply-edit` already computed, which drops a full
redundant serialization per `replace_all` and makes its docstring's "one
serialization rather than one per match" true.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fD5YPdm6TNR2WfU3k7MMB
* Follow collection_write's contract changes into document_write
Slice 03 changed two shared contracts under this branch. Both merged clean
and both were invisible to a targeted kondo run, which cannot resolve vars
across namespaces.
`v2.write/readback` gained an `ack-keys` parameter — caller-supplied keys
that survive when the response degrades for a token without the read scope.
`document_write` has none: nothing it echoes back is caller-supplied rather
than read-gated, so it passes nil, as collection_write does.
`registry/call-tool` now returns `{:error …}` when the registry refuses
before dispatch and `{:result mcp-content}` after the handler runs, so
`:isError` and `:content` moved under `:result`. The test helpers still read
them at the top level, which made every refusal look like a success and hid
both budget tests behind a vacuous pass. `error-text` reads either shape from
one place: a schema violation refused at the boundary carries `:error`, a
handler's teaching error carries `:result {:isError true}`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fD5YPdm6TNR2WfU3k7MMB
* Record a recent view when a document is created
`command palette > should display collection names for documents in recents`
was failing on this branch (2 of 2 runs), passing 5/5 on master with a
byte-identical spec.
The documents extraction swapped `get-document` for `read-check` +
`hydrate-document` on the create path, so `:event/document-read` stopped
firing. That event's handler is the only thing that writes the `RecentViews`
row (documents/view_log.clj), and the command palette surfaces a just-created
document only through recents — the spec POSTs a document and never visits it.
Creating is arguably not reading, but recents rides on the same event, and
UXW-1786 expects a new document to be findable. Restored `get-document` and
dropped `post-document-does-not-record-view-test`, which pinned the new
behaviour; it and the e2e spec cannot both hold. The POST/PUT symmetry that
motivated it does not apply: PUT never recorded a view on master, POST always
did.
Added `post-document-records-a-recent-view-test` beside the GET/PUT view tests
so the expectation is asserted at the API level — the Cypress spec reports an
empty palette without saying why.
Decoupling recents from `:event/document-read` would let view counts stop
treating creation as consumption; that is a separate change.
* Make the session-signature tests capable of failing
The two signature assertions in `mcp-ui-capability-claim-must-be-signed-test`
passed for the wrong reason, so they proved nothing about signing.
`#"\\."` matches a literal backslash followed by any character, not an escaped
dot, so the split was a no-op: `uuid` received the whole session id and
`payload`/`sig` were nil. The reconstructed ids were malformed and rejected
before signature verification ever ran. Replacing `session-payload-signature`
with a constant left all six assertions green.
Fixing the split alone was not enough. The tampering case re-encoded the SAME
payload onto an already `ui true` id, so it rebuilt byte-identical and was
believed for the right reason. It now mints a `ui false` id and swaps in a
`ui true` payload under the original's signature, which is the escalation the
signing exists to stop.
Guards added so this cannot silently rot again: the splits assert they reached
a signature, the lifted signature asserts it differs from the original, and a
control asserts the unmodified id claims nothing.
Verified by mutation: with `session-payload-signature` stubbed to a constant
the test now fails 3 assertions, where it previously passed clean.
* Pin the transform search gate without writing a global setting
transform-visibility-is-superuser-only-test wrapped its body in
(mt/with-temporary-setting-values [transforms-enabled true]
(mt/with-premium-features #{:transforms-basic} ...))
which left `transforms-enabled` explicitly set for the rest of the JVM, and
took metabase.metabot.tools.transforms-test and metabase.revisions.api-test
down with it on every backend shard.
`with-temporary-setting-values` does not restore a setting to *unset*. It
captures `(setting/get setting-k)` on entry and writes that value back in its
`finally` (test/metabase/test/util.clj:552-609). `transforms-enabled` is unset
here, so the capture runs its getter's `cloud-enabled-transforms?` fallback
((and (is-hosted?) (has-feature? :transforms-basic))) and yields a computed
boolean rather than nil. Restoring that boolean persists a real Setting row,
so `explicit-transforms-enabled` stops returning nil and the fallback every
other transform test relies on is dead for the remainder of the run.
The capture also happened before any premium features were stubbed -- the
setting form was the outer one -- so it read ambient state, not
#{:transforms-basic}.
Downstream that lands on the permission check: `transform-readable?`
(src/metabase/transforms/models/transform.clj:35-42) gates on
`check-feature-enabled` *ahead* of the `api/*is-superuser?*` bypass, and that
gate begins with `(setting/get :transforms-enabled)`
(src/metabase/premium_features/token_check.clj:715-730). Once the setting is
pinned false, even admin callers are denied, which is why the revisions test
saw 403 on its superuser case and the metabot transform tools returned
"You don't have permissions to do that.".
Pin the gate at its actual seam instead: redefine
`transforms.gating/enabled-source-types`, which is the single place
search/impl.clj:364 reads it from. That covers what the test needs -- the
in-place engine drops transform for everyone when the enabled set is empty --
without touching persisted state, so there is nothing to restore and nothing
to leak.
Adding `:hosting` to the feature set would also have made the leaked value
harmless, but it only changes *which* value leaks; the setting would still
come back explicitly set. The underlying defect in
`do-with-temporary-setting-value!` (capturing the computed value rather than
the raw stored one) is left for a separate change -- it has ~229 call sites
and wants its own audit.
* Move the document comment queries into comments.db
Master added a clj-kondo hook requiring Toucan2 query calls to live in
`metabase[-enterprise].<module>.db`. This branch had added two `t2/select`
calls to `comments/models/comment.clj`. Neither side fails alone; merging the
new rule with the existing calls turned CI's Clj-Kondo job red with two
warnings.
`document-comments` and `document-child-target-counts` now live in
`comments/db.clj` alongside the module's other queries. The model functions
keep their logic — the deleted-comment scrub, `:creator` hydration, the
`select-keys` projection — and delegate only the query.
Full-tree kondo: 0 errors, 0 warnings; no other file violates the new rule.
:module comments 174 assertions, 0 failures / 0 errors. :module documents
1408 assertions with only the two pre-existing `collection-test` errors, which
predate this change.
* Move document_write's Toucan calls into mcp.db
#82099 added `:metabase/t2-query-namespace`, which confines application
database query calls to `metabase[-enterprise].<module>.db`. It arrived with
the master merge and made this file the only violator in the tree — three
warnings, and CI requires zero.
`select-by-ids` covers the two `:in` lookups and mirrors the existing
`select-one-by-id`, which already takes a dynamic model.
`select-users-where` takes an assembled HoneySQL clause, which is looser than
the "direct Toucan 2 call with no additional logic" the namespace documents.
The clause is built from the `user-visibility` setting, group membership and
the superuser flag — it decides which users the caller may resolve at all, so
it is a permission decision. Moving it into a db namespace would hide it and
pull those dependencies in. #066c63b88ee, the comments move on this branch,
set the precedent: the logic-bearing fn stays, only the query moves.
`select-by-ids` deliberately has no `{:pre [(seq ids)]}` despite
`select-one-by-id` guarding its argument. An AssertionError is not an
Exception, so it would bypass the `catch Exception` in `smart-link-rows` and
turn a logged warning into a propagated failure — a behavior change this
relocation should not smuggle in. Callers guard the empty case instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fD5YPdm6TNR2WfU3k7MMB
* mcp-v2.04 prep: portable-dialect execution rework in the agent lib (#81248)
* MCP v2 tool: bookmark_content
Adds the v2 bookmark_content tool — per-user favorites, keyed by (type, id).
Extraction: bookmarks/api.clj factors bookmark!/un-bookmark!/bookmarked? out
of the REST endpoints so REST and MCP share them. The POST endpoint now
creates via app-db/select-or-insert! (concurrency-safe: a racing duplicate
returns the existing row instead of tripping the unique constraint); its 400
on already-bookmarked is unchanged. REST tests extended, existing pass.
Unlike REST, both tool directions are idempotent (see ns docstring for why),
both read-check the item, and the name echo is gated on the read scope so a
write-only token can't use it as a name oracle.
Tests: 9 tool tests + bookmarks REST suite green.
Review question for this PR: one tool — is bookmark_content's contract right?
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
Also lands (first consumer, per the common.clj slimming in the core PR):
- NEW v2/resolve.clj — entity-id / numeric-id resolution + the read-check
pairing with the not-found collapse (+ resolve_test, moved from common_test).
- NEW v2/write.clj — readback (the scope-gated write echo).
CI guard: this slice's mcp -> bookmarks edge put `bookmarks` into the
modules-that-trigger-driver-tests set (mage's module-graph ratchet, 44 -> 45).
Bookmark logic cannot affect driver behavior — the edge is a consumer edge —
so `bookmarks` joins driver-affecting-overrides rather than bumping the
ratchet. Full mage suite green (82 tests).
Review nit (round 1): fetch-item's flavor-mismatch teaching error printed
"is a null" for a card type outside question/model/metric; guard the nil case
with a type-agnostic message.
--- Round-2 /review-pr response ---
IMPORTANT (per-user-test could not fail on its named assertion) FIXED. The test called the tool
bare, so a tool-level error was discarded and the assertions passed regardless. It now routes
both calls through tool-result (throws on tool error) and has :lucky bookmark-then-un-bookmark
its own row, so the "one user's bookmark is invisible to another's un-bookmark" claim has a live
counterfactual. Proven fail-when-broken (stubbing un-bookmark! to a no-op turns the test red).
MINOR fixed: the id schema now requires a positive int (matching REST's ms/PositiveInt); the
degraded write-scope readback now surfaces :bookmarked (via a new backward-compatible ack-keys
arg on v2.write/readback, so a write-only caller still learns the resulting state).
MINOR deferred (pre-existing REST behavior, out of a tool-slice's scope, confirmed structurally):
archived content is bookmarkable but filtered from listings; the Trash collection is bookmarkable;
readback's :url key has no producer on this branch (lands with collection_write). Each is shared
with the existing REST bookmark endpoints; changing it means deciding REST semantics.
Fresh-JVM: bookmark-test 9 tests / 43 assertions / 0 failures; kondo 0/0; modules unchanged.
* MCP v2 tool: collection_write
Adds the v2 collection_write tool — create/update/move/archive collections.
Extraction: collections/update.clj lifts update-collection! out of
PUT /api/collection/:id — the four privates (move-collection!,
archive-collection!, move-or-archive-collection-if-needed!,
maybe-send-archived-notifications!) move verbatim (docstrings de-REST-ified,
notification alias renamed); the endpoint becomes a thin delegator through
the collections.core facade, mirroring the existing create-collection!
pattern. Verified move: bodies diff clean modulo whitespace/docstrings.
The foundation's REST-test changes for this file are all the BROWSE
extraction's repoints, so master's collections_rest api-test runs UNCHANGED
against this branch — that unchanged suite is the extraction check
(fresh-JVM: 202 tests, 941 assertions, green, incl. 23 tool tests).
Deferred to the content tool PR: echo-matches-concise-read-test (the write
echo vs get_content cross-check) — it requires tools/content.
Review question for this PR: one tool — is collection_write's contract right?
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
Also lands (first consumer, per the common.clj slimming in the core PR):
- v2/resolve.clj grows collection resolution (resolve-collection-id{,-or-personal}).
- v2/write.clj grows `_write` method dispatch (dispatch-write + expand-clear).
- common.clj grows frontend-url. Tests moved alongside (resolve_test, write_test).
Review nits (round 1): fix the self-contradictory `name` field description
("Create only (editable on update)" -> "Required on create; editable on
update"); drop structuredContent from the write echo to match the stack's
text-only convention (no concrete programmatic consumer, no outputSchema).
CI kondo fix: the update-collection! extraction moved the only `tru` caller in
collections_rest/api.clj into collections/update.clj, leaving a dead
`[metabase.util.i18n :refer [tru]]` require — an unused-require warning, which
`mage kondo` (CI's check) exits nonzero on. Removed it.
--- Round-2 /review-pr response ---
IMPORTANT (archived:true + parent_id silently discarded the move) FIXED. The dispatch tested
archived first and returned, so a caller passing both got a trashed collection and no move, with
no signal — defeating the tool's "never believes an ignored field took effect" invariant. It is
now a teaching error naming the fix (move first then archive, or pass parent_id on a later
archived:false restore). Regression test added.
MINOR fixed: the four (is (tool-error ...)) assertions now match the specific expected message,
not any error text; the :name destructure was renamed (coll-name) so it no longer shadows
clojure.core/name.
Fresh-JVM: collection-test 24 tests / 65 assertions / 0 failures; kondo 0/0; modules unchanged.
--- Close-out M5 ---
MINOR (bare tool-error assertion): authority-level-update-test now asserts the specific "paid feature" message (parity with the create-side twin), so a wrong error text fails.
* MCP v2 prep: portable-dialect execution rework in the agent lib
The shared machinery behind every portable-MBQL write path — metabot's
construct_notebook_query today, and the v2 MCP tools (metric_write,
question_write, execute_query) as they land on the stack:
- metabot/tools/construct: execute-representations-query reworked (new
signature; numeric-id source detection; recovery-hint wrapping around
resolution failures).
- NEW metabot/tools/recovery_hints: the standard 'what to call next'
sentences appended to dialect teaching errors.
- agent-lib representations{,.repair}: the corresponding repair/resolve
pipeline changes (resolve.clj needed nothing beyond master's).
Master's post-fork pMBQL→'MBQL 5' doc/message renames are re-applied on
top of the pulled files (wholesale pulls from the foundation would have
reverted them).
Behavior check: master's agent-lib suites (repair_test, resolve_test) and
metabot tool tests run UNCHANGED and green in a fresh JVM.
Review question for this PR: does construct_notebook_query (the existing
metabot consumer) behave identically where not deliberately extended?
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
Review fixes (adversarial review round 1):
- F1: the agent-api /v2/construct-query endpoint and the slackbot query tool
called execute-representations-query without :recovery-hint, so both v1
surfaces relayed bare pipeline errors where slice 03 had baked guidance into
the throw sites. Wired recovery-hints/recovery-hint into both (as the ns
docstring already claims every v1 entry point does). Proven: a URI-in-
source-table error now carries the "use the portable FK" sentence on the
agent-api path.
- F4: node-table-fks never collected a numeric field ref's target table, so it
was never permission-checked; the no-fk-path error then named it from the
un-sandboxed metadata provider — a table-name oracle for any probed id once a
numeric binder lands. Fall back to the bare id for the numeric case (matching
the vector case's own-name behavior). Dark today (no binder on this slice),
fixed before it can go live.
Test fix: the F1 change gave slackbot's execute-representations-query call a
second (:recovery-hint) arg, but slackbot_query_test stubbed the fn with 1-arg
lambdas — the extra arg broke the stubs (nil results). Made the 4 stubs
variadic ([external-query & _]) so they accept the opts map; production change
unchanged. Suite green (53 assertions).
--- Round-2 /review-pr response ---
CRITICAL (numeric field refs bypass the pre-repair permission check) FIXED. A bare numeric field
id in a query node was never resolved to its table, so node-table-fks skipped it and query-check
never gated the field's table. node-table-fks now emits the source-field as a tagged numeric ref;
resolve-table-fk resolves it to the field's table via the permission-aware metadata provider and
checks that table. Two regression tests: one asserts the FK-target table is permission-checked,
one asserts a forbidden FK-target table blocks the call.
CRITICAL (export-source-field-portable writes an un-permission-checked FK path) REFUTED, and the
refutation is closed by the fix above. The exported source-field is the FK-BEARING column, which
by definition lives on the source table — the same table node-table-fks now collects and
query-check gates. Verified in a fresh JVM: the exported field resolves to a column on the source
table, not the FK-target; denying perms on the target raises 403 before export runs. The reviewer
conflated the FK-target table with the FK-bearing column.
IMPORTANT fixed: the recovery_hints ns docstring no longer asserts wiring that does not exist (it
now states the v2 query slice must pass it, and that until then it has no production caller); the
:no-fk-path message and the :unknown-table key collision are corrected. MINOR fixes applied.
Fresh-JVM: construct-representations-test + repair-test 228 tests / 616 assertions / 0 failures;
kondo 0/0; modules unchanged.
* Read-check the numeric source-card unconditionally
The numeric `source-card:` branch guarded its read check on
`api/*current-user-id*` being bound, so a caller with no current user got the
card's database id back without any permission check. Its portable sibling,
`tools.u/get-card-by-entity-id`, read-checks unconditionally — two branches of
one function disagreeing on whether permissions apply.
The guard was not there to avoid a crash: `read-check` -> `mi/can-read?`
resolves Card perms against the current-user permission set and denies when
there is no user, so removing the guard fails closed.
Not reachable in production yet — nothing binds `*numeric-ids-allowed?*` to
true until the v2 query slice lands — but this branch is where the dialect is
authored, so it is where the check belongs.
Adds the numeric `source-card` coverage the other numeric branches already
had: database-id resolution, that the card row reaches `read-check`, a denied
card surfacing 403, the same with `*current-user-id*` nil as the regression
guard, and an unknown id surfacing `:unknown-card`. Also covers two branches
that had none: `:unknown-table-id` for a numeric `source-table` that resolves
to nothing (kept distinct from the portable form's `:unknown-table`, since the
two want different recovery vocabulary), and `with-recovery-hint` passing an
error through untouched when there is no hint to add.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nng1SV562GGqn1Qf8dQmpM
* Route numeric source-card through the read-checked content store
The numeric-id dialect accepts a bare numeric `source-card`, but
`import-mbql` matched only `portable-id?`, which requires a string. A
numeric id therefore never reached the ContentStore -- and on agent
paths the store is the `read-checked` one, so that lookup is where the
permission check lives. The only remaining check was the explicit
`read-check` in `resolve-database-id-from-first-stage`, which by
construction sees `stages[0]` only, and which a stage carrying both
`source-table` and `source-card` skips entirely (the `cond` takes the
`source-table` branch).
Downstream, the repair pass's cross-stage column inference runs for any
stage with a `source-card` and reports the card's column names when a
referenced column is missing, so an unchecked card surfaced its columns
to a caller without access to it.
Fix at the resolution chokepoint rather than at the first stage:
`import-mbql` now has a numeric `source-card` branch, gated on
`*numeric-ids-allowed?*` so the portable-only surface is untouched.
Because that walk recurses, this covers every stage and every join
instead of just the one the database-id lookup happens to inspect. The
new `import-card-by-id` mirrors `import-card-by-entity-id`, including
its cross-database guard, and returns the id it was handed -- the
routing, not the translation, is the point.
Also drop the `(when api/*current-user-id* ...)` guard on the numeric
card's `read-check` in `construct.clj`. The entity_id branch checks
unconditionally and the numeric table branch fails closed; only this one
failed open for an unauthenticated caller.
Verified against a real appdb with an unprivileged user: both shapes now
raise 403 with no column names in the message or ex-data, where before
they resolved and reported them. The new tests fail on the unfixed tree
and pass here.
Test evidence: resolve.mp-test 41 tests / 136 assertions;
construct-representations + repair + resolve 249 tests / 707 assertions;
metabot tools 28 tests / 386 assertions; new
construct-numeric-permissions-test 4 tests / 11 assertions. All green.
* Require numeric ids to denote something the query may reference
Three findings from the slice-04 review, all the same bug: the numeric
dialect treats an LLM-authored integer as a resource reference and acts
on it directly. The portable dialect never had to state these rules --
it resolves *names* through a metadata provider that is already scoped
to the query's database and filtered to visible rows, so "in this
database" and "a column the warehouse exposes" fall out of resolution.
A bare id skips all of it.
`visible-table` / `visible-field` put the contract back: everything the
numeric dialect accepts is looked up through the query's own provider,
and the active/visibility rules are re-applied on top, because
`metadata-spec->honey-sql` deliberately drops its filter for by-id
lookups (`active-only?` is `(not (or id-set name-set))`). That default
is right for trusted callers asking for a specific row and wrong for an
agent-authored id.
1. Cross-database tables. `resolve-table-fk` returned a `pos-int?`
verbatim, so a join could pull a table from another database into a
query. `api/query-check` does not close this: it asks whether the
caller may query table N, never whether N belongs to this query's
database, so it passes for anyone with access to the other database.
Traced on the unfixed tree, the check ran against the foreign table
(id 9, db_id 2) inside a query targeting db 1 and passed; the query
failed later only because repair could not find the table in the
wrong provider. The refusal was incidental, not a boundary.
`resolve/mp.clj` has cross-database guards for cards, measures and
segments and none for tables, because the portable path never needed
one.
2. Sensitive / inactive / retired fields, and inactive or hidden tables,
were reachable by id and came back as result columns. The portable
dialect cannot name them at all -- they are absent from the by-name
fetch.
3. Table-existence oracle. The numeric branch of
`resolve-database-id-from-first-stage` distinguished "no such table"
from "table you cannot see" before any permission check had run,
which enumerates table ids instance-wide. It now read-checks and
reports both the same way. This lookup cannot go through the provider
-- it is what decides which provider to build -- so the check lives
there.
`assert-numeric-ids-are-referenceable!` raises the agent-facing errors
ahead of the permission sweep; `resolve-table-fk` independently refuses
the same ids, so the sweep fails closed even if a future caller skips
the assertion. Both new error keys already have recovery hints on both
surfaces. No-ops entirely on the portable-only surface.
Test evidence: 9 tests / 21 assertions in the new ns, all green, and 11
assertions across all three findings fail on the unfixed tree. Existing
suites unchanged: construct-representations + repair + resolve +
resolve.mp, 290 tests / 843 assertions, 0 failures.
Also corrects two wrong premises the tests caught: a query whose only
source is a foreign table is not a violation (the provider is simply
built for that database), and
`with-non-admin-groups-no-root-collection-perms` does not gate warehouse
tables, so the oracle test revokes data permissions instead.
* Read-check numeric metric, segment, and measure refs too
Follow-up to the numeric `source-card` fix, from review feedback: the
same gap existed for every other content reference, not just the source.
`import-mbql` guarded its content branches with `portable-id?`, which is
string-only. A numeric `["metric" {} 42]`, `["segment" {} 42]`, or
`["measure" {} 42]` therefore matched no branch at all -- it was never
resolved, so it never reached `import-fk`, so the read-checked content
store never saw it. Same shape as the `source-card` leak, across seven
more branches.
Rather than give each branch a numeric twin -- fourteen branches, and a
later one easy to forget -- the guard itself widens: `content-ref?` is
"a portable entity_id, or a bare id on the numeric-id surface", and
every content branch shares it. `import-fk` then dispatches numerics per
model, so `import-card-by-id` gains `import-measure-by-id` and
`import-segment-by-id` siblings, each carrying the same cross-database
guard as its portable counterpart. Metric refs resolve as Cards, which
is what they are.
Also from review:
- `:unknown-card-id` for a numeric card miss, matching `export-card-by-id`
and the `:unknown-table-id` precedent -- a numeric miss and a portable
miss want different recovery advice. Updates the one test that asserted
the old key.
- `resolve-database-id-from-first-stage`'s docstring said `:unknown-table`
where the code throws `:unknown-table-id`.
- Dropped a `^clojure.lang.ExceptionInfo` hint on `with-recovery-hint`
that propagates nowhere.
- `numeric-field-id-error!` no longer claims lib rejects bare numeric ids,
which stopped being true once the numeric surface landed.
The read-check on the numeric `source-table` lookup moved into
`metabot.db/readable-active-table-database-id`, keeping Toucan calls in
the db namespace where this branch put them.
Test evidence: 308 tests / 883 assertions across
construct-numeric-permissions, construct-representations, repair,
resolve, and resolve.mp -- all green.
* Correct a test comment about the PathSchema stack overflow
The comment called it a nested-quantifier backtracking problem. It is
not: `path-char-rx` excludes `/`, so segment boundaries are forced and
there is nothing to backtrack over — a 218-character failing input
matches in 0 ms. java.util.regex simply recurses once per quantifier
iteration on a *successful* match, so depth grows linearly with segment
count (~2000 segments overflows a default stack on its own). Atop the
pipeline's own frames the threshold drops far enough to hit it here.
Same conclusion either way — pre-existing, dev/test only, unrelated to
this branch — but the stated mechanism was wrong and would have sent the
next reader after a bug that isn't there.
* Say why the visibility lookups swallow every exception
Review noted the catch-all was undocumented. It is deliberate: this pass
decides only what a numeric id may name, so failing closed is the safe
direction and a provider outage is diagnosed elsewhere. Say so, rather
than leaving a bare `(catch Exception _ nil)` to read as an oversight.
* Drop a redundant do in a test helper
`try` already has an implicit body; kondo flagged it.
* Build the test fixtures with Lib instead of mt/mbql-query
`mt/mbql-query` is deprecated since 0.61.0, and clj-kondo exits 2 on
warnings — so the two call sites this ns added were failing the
Clj-Kondo CI job outright, not merely producing noise. Its own docstring
says to use Lib for new tests; done.
Same queries, same result columns: the victim card keeps its named
`SecretMargin` expression and its aggregation, which is what makes a
column-name leak unmistakable in the assertions.
Full-repo kondo now reports 0 errors, 0 warnings (was 2 warnings, exit 2).
* Close four numeric-id permission gaps found in review
All four are in the numeric-id handling this branch added.
**Two-element field refs escaped collection.** `node-table-fks` checked the
portable form in slots 2 *and* 1 but the numeric form in slot 2 only. Repair
inserts the options map later, so an LLM-authored `["field", 42]` still carries
its target in slot 1 when the collection pass runs — that shape reached
resolution unchecked. Both forms now read both slots.
**Field visibility ignored the parent chain.** A JSON column unfolds into child
fields carrying their own visibility, so an ordinary-looking child of a
`:sensitive` parent was referenceable by id — reaching inside a column the
warehouse marked unqueryable. `visible-field` now walks `:parent-id` and
requires every ancestor to be visible too, bounded so a corrupt chain cannot
spin.
**Unreadable and missing content were distinguishable.** A denial surfaced
`read-check`'s 403 while an absent id produced a 400 `:unknown-…`. Content ids
are sequential and easy to guess, so the status code alone told a caller whether
a hidden card, measure, or segment exists — the same oracle already closed for
the numeric table lookup. `maybe-read-check` now collapses a 403 to `nil`, which
every resolver already translates into its not-found error, so one change covers
all six store methods; non-403 exceptions still propagate, since those are real
errors rather than permission answers. `resolve-database-id-from-first-stage`
read-checks outside the store, so it got the same collapse.
**The table lookup denied users it should have allowed.** `mi/can-read?` for a
Table is polymorphic over the instance, and its published-collection branch reads
`:is_published` and `:collection_id`.
`readable-active-table-database-id` selected only `:id`/`:db_id`, so that branch
evaluated against absent keys and a user whose access came through a published
collection was told the table does not exist. It selects the whole row now. This
one was a fail-closed regression introduced by the previous commit's oracle fix.
Tests: four new cases, each verified to fail on the pre-fix tree. Existing tests
that asserted the 403 shape are updated to the collapsed one — their intent (the
check runs, fails closed, does not depend on a bound user, never leaks an
entity_id) is unchanged and still asserted.
319 tests / 935 assertions green across construct-numeric-permissions,
construct-representations, repair, resolve, resolve.mp, and content-store.
Full-repo kondo: 0 errors, 0 warnings.
* Close the query-check oracle and cover scoped content refs
Second review round. Both blocking findings are the same root cause as the
one the previous commit fixed — `mi/can-read?` is strictly weaker than
`mi/can-query?` for a Table, since only `can-read?` carries the
`manage-table-metadata :yes` disjunct — reached by two routes that fix did
not cover.
**The table oracle was still open one line later.** The previous commit
closed it at `readable-active-table-database-id`, which gates on
`can-read?`. `check-source-table-query-permissions!` runs immediately after
and gates on `api/query-check` → `can-query?`. So a metadata-only user
passed every earlier gate and got a bare 403 there, while a nonexistent id
got a 400 — confirming the table exists, is active, and is in this database.
`query-check-table!` now collapses a denial on a *numeric* reference into the
same `:unknown-table-id` an absent id produces. The portable path keeps its
403: its references are unguessable names, so there is nothing to enumerate
and the accurate status is worth more.
**Numeric measure and segment refs never reached a query-check at all.**
`node-table-fks` matched maps and `["field" …]` vectors, so `["measure" {} 44]`
and `["segment" {} 43]` fell through both branches — the tables they are
scoped to were never collected, and `assert-numeric-ids-are-referenceable!`
iterates the same collection, so it skipped them too. They do get
`api/read-check` through the store, but `can-read?` on both models delegates
to the Table's `can-read?` and lands on the same weaker gate. Adds a
`::scoped-content` tag mirroring the existing `::field-id` one, resolved to
the owning table through the read-checked store. Metric refs are Cards and
are deliberately not covered: a card pulls in whatever tables its own query
names, which this pre-flight sweep cannot see, and the QP re-checks them at
execution.
Also from the same review:
- `snippet-id` had been widened to `content-ref?`, but no resolver implements
`NativeQuerySnippet` — a numeric one fell through `import-fk`'s model tests
to `not-implemented!` and surfaced as a bare 501 with no `:agent-error?`,
never reaching the store. Narrowed back to `portable-id?`; `content-ref?`'s
docstring no longer claims snippets.
- `active-table-database-id` was dead: the pre-oracle-fix version its checked
sibling superseded within this branch, with the shorter name that reads as
the obvious choice. Deleted. The one test "stubbing" it was stubbing the
dead var and hitting the real app DB, passing only because id 999 happened
not to exist — retargeted to the function actually under test.
- `*numeric-ids-allowed?*`'s docstring described its readers in the future
tense; this PR is what added them. Rewritten to state the contract instead
of the rollout.
- `import-card-by-id`'s docstring still said a denial becomes "a 403", which
the previous commit's collapse made false.
- `visible-field`'s docstring now says the parent walk checks each ancestor's
visibility but not its `:table-id`, and why that is sound.
Tests: two new cases, both mutation-checked. The segment one deliberately puts
the segment on a different table than the stage's `source-table`, so it cannot
be satisfied by the source table's own check — the first version could, and
passed with the fix reverted.
321 tests / 943 assertions green. Full-repo kondo: 0 errors, 0 warnings.
* Keep the export fallback suppressed for a refused card
The 403 collapse in the previous commits removed a signal an internal
caller depended on. `llm-shape/export-query-for-llm` documents that "a
permission-refused export renders nothing at all rather than the
fallback", and implemented that by catching the 403. Once the content
store turned a denial into `nil`, that 403 stopped arriving, so a card
the caller may not read fell through to `query-edn-fallback` and printed
the raw query map.
No entity_id leaked — the export still refuses to emit one, so the
fallback showed `:source-card 1`, the numeric id the caller already
supplied. But the caller's contract changed, and the intent of that catch
was to render nothing.
The two requirements are not in conflict. Agent-facing, a denial and a
miss must be indistinguishable: same status, same error key, so a
guessable id cannot be used to probe for hidden content. Internally,
callers still need to tell them apart to decide whether falling back is
appropriate. So the collapse keeps the uniform error and records the
refusal out of band, in `*last-lookup-refused?*`, which says only that
*some* lookup was refused — never which row, so it cannot itself become
an oracle.
Caught by `llm-shape-test/transform-query->text-source-card-permission-test`,
which I had not run: it lives outside the affected-set I had been
checking. The second review round flagged this mechanism and I recorded
it as out of scope; it was not.
175 tests / 768 assertions green across llm-shape, content-store,
construct-numeric-permissions, construct-representations, and
resolve.mp. Full-repo kondo: 0 errors, 0 warnings.
* Collapse the read-check denial for numeric lookups only
The collapse was too broad. It lived in `maybe-read-check`, which every
content lookup passes through, so it applied to entity_id lookups as well
as numeric ones — and the two surfaces want opposite things.
Numeric ids are sequential and trivially guessable, so a distinguishable
denial is an existence oracle and the 403 has to become the same
not-found a missing id produces. A 21-character NanoID is not guessable,
so there is nothing to enumerate, and callers there depend on the accurate
status: `POST /api/agent/v2/construct-query` returns 403 for a metric
whose card the caller cannot read, and `llm-shape/export-query-for-llm`
suppresses its EDN fallback on one.
Collapsing both meant the entity_id path started answering 400
`:unknown-card` where it had answered 403 — caught by CI in
`agent-api.api-test/construct-query-permission-checks-test`, which asserts
the 403 for exactly that case.
`maybe-read-check` now takes `collapse-denial?`: true for the three
`-by-id` methods, false for the three `-by-entity-id` ones. The refusal is
still recorded in `*last-lookup-refused?*` either way, so callers that need
to know a denial happened without depending on the status keep working.
This is the second time the chokepoint's breadth cut the wrong way — it is
what made one edit cover all six store methods, and also what swept in a
surface that did not want the change. The split keeps the single
chokepoint and makes the difference explicit at it.
462 tests / 1698 assertions green across construct-numeric-permissions,
construct-representations, repair, resolve, resolve.mp, content-store,
llm-shape, and agent-api. Full-repo kondo: 0 errors, 0 warnings.
---------
Co-authored-by: Eric Normand <ericwnormand@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* mcp-v2.05 metric_write (#81249)
* MCP v2 tool: bookmark_content
Adds the v2 bookmark_content tool — per-user favorites, keyed by (type, id).
Extraction: bookmarks/api.clj factors bookmark!/un-bookmark!/bookmarked? out
of the REST endpoints so REST and MCP share them. The POST endpoint now
creates via app-db/select-or-insert! (concurrency-safe: a racing duplicate
returns the existing row instead of tripping the unique constraint); its 400
on already-bookmarked is unchanged. REST tests extended, existing pass.
Unlike REST, both tool directions are idempotent (see ns docstring for why),
both read-check the item, and the name echo is gated on the read scope so a
write-only token can't use it as a name oracle.
Tests: 9 tool tests + bookmarks REST suite green.
Review question for this PR: one tool — is bookmark_content's contract right?
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
Also lands (first consumer, per the common.clj slimming in the core PR):
- NEW v2/resolve.clj — entity-id / numeric-id resolution + the read-check
pairing with the not-found collapse (+ resolve_test, moved from common_test).
- NEW v2/write.clj — readback (the scope-gated write echo).
CI guard: this slice's mcp -> bookmarks edge put `bookmarks` into the
modules-that-trigger-driver-tests set (mage's module-graph ratchet, 44 -> 45).
Bookmark logic cannot affect driver behavior — the edge is a consumer edge —
so `bookmarks` joins driver-affecting-overrides rather than bumping the
ratchet. Full mage suite green (82 tests).
Review nit (round 1): fetch-item's flavor-mismatch teaching error printed
"is a null" for a card type outside question/model/metric; guard the nil case
with a type-agnostic message.
--- Round-2 /review-pr response ---
IMPORTANT (per-user-test could not fail on its named assertion) FIXED. The test called the tool
bare, so a tool-level error was discarded and the assertions passed regardless. It now routes
both calls through tool-result (throws on tool error) and has :lucky bookmark-then-un-bookmark
its own row, so the "one user's bookmark is invisible to another's un-bookmark" claim has a live
counterfactual. Proven fail-when-broken (stubbing un-bookmark! to a no-op turns the test red).
MINOR fixed: the id schema now requires a positive int (matching REST's ms/PositiveInt); the
degraded write-scope readback now surfaces :bookmarked (via a new backward-compatible ack-keys
arg on v2.write/readback, so a write-only caller still learns the resulting state).
MINOR deferred (pre-existing REST behavior, out of a tool-slice's scope, confirmed structurally):
archived content is bookmarkable but filtered from listings; the Trash collection is bookmarkable;
readback's :url key has no producer on this branch (lands with collection_write). Each is shared
with the existing REST bookmark endpoints; changing it means deciding REST semantics.
Fresh-JVM: bookmark-test 9 tests / 43 assertions / 0 failures; kondo 0/0; modules unchanged.
* MCP v2 tool: collection_write
Adds the v2 collection_write tool — create/update/move/archive collections.
Extraction: collections/update.clj lifts update-collection! out of
PUT /api/collection/:id — the four privates (move-collection!,
archive-collection!, move-or-archive-collection-if-needed!,
maybe-send-archived-notifications!) move verbatim (docstrings de-REST-ified,
notification alias renamed); the endpoint becomes a thin delegator through
the collections.core facade, mirroring the existing create-collection!
pattern. Verified move: bodies diff clean modulo whitespace/docstrings.
The foundation's REST-test changes for this file are all the BROWSE
extraction's repoints, so master's collections_rest api-test runs UNCHANGED
against this branch — that unchanged suite is the extraction check
(fresh-JVM: 202 tests, 941 assertions, green, incl. 23 tool tests).
Deferred to the content tool PR: echo-matches-concise-read-test (the write
echo vs get_content cross-check) — it requires tools/content.
Review question for this PR: one tool — is collection_write's contract right?
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
Also lands (first consumer, per the common.clj slimming in the core PR):
- v2/resolve.clj grows collection resolution (resolve-collection-id{,-or-personal}).
- v2/write.clj grows `_write` method dispatch (dispatch-write + expand-clear).
- common.clj grows frontend-url. Tests moved alongside (resolve_test, write_test).
Review nits (round 1): fix the self-contradictory `name` field description
("Create only (editable on update)" -> "Required on create; editable on
update"); drop structuredContent from the write echo to match the stack's
text-only convention (no concrete programmatic consumer, no outputSchema).
CI kondo fix: the update-collection! extraction moved the only `tru` caller in
collections_rest/api.clj into collections/update.clj, leaving a dead
`[metabase.util.i18n :refer [tru]]` require — an unused-require warning, which
`mage kondo` (CI's check) exits nonzero on. Removed it.
--- Round-2 /review-pr response ---
IMPORTANT (archived:true + parent_id silently discarded the move) FIXED. The dispatch tested
archived first and returned, so a caller passing both got a trashed collection and no move, with
no signal — defeating the tool's "never believes an ignored field took effect" invariant. It is
now a teaching error naming the fix (move first then archive, or pass parent_id on a later
archived:false restore). Regression test added.
MINOR fixed: the four (is (tool-error ...)) assertions now match the specific expected message,
not any error text; the :name destructure was renamed (coll-name) so it no longer shadows
clojure.core/name.
Fresh-JVM: collection-test 24 tests / 65 assertions / 0 failures; kondo 0/0; modules unchanged.
--- Close-out M5 ---
MINOR (bare tool-error assertion): authority-level-update-test now asserts the specific "paid feature" message (parity with the create-side twin), so a wrong error text fails.
* MCP v2 tool: metric_write
Adds the v2 metric_write tool — create/update metrics from an MBQL
definition (either dialect) or a query_handle.
Extraction: queries/card_write_checks.clj lifts the pre-write check stack
(check-card-can-be-saved!, check-no-save-cycle!, actual-collection-id,
check-allowed-to-create-card!, check-allowed-to-update-card!) out of
queries_rest/api/card.clj (−98; taken wholesale — zero master drift) so
REST and MCP run identical checks. queries/core.clj facade HAND-MERGED:
master's post-fork parameter-template-tag? export kept; foundation's
infer-metadata-with-model-overrides export skipped (no consumer here yet).
Projection ownership fix (the WORKLOG's load-order landmine, hit live):
the :metric key projection moves from content.clj's load-time side effect
into this tool — the type's owner — so metric_write works without
tools/content loaded. The content PR drops its copy.
Deferred tests (see WORKLOG ledger): the execute_query handle flow → query
PR; the get_content echo-parity and definition round-trip → content PR.
card_test's param-fields-hydrated-test is a post-fork master addition and
stays (foundation's deletion is fork lag, not intent).
Fresh-JVM: metric tool suite + agent-lib suites + full card REST suite
green (1155+ assertions across the adjudication runs).
Review question for this PR: one tool — is metric_write's contract right?
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
Also lands (first consumer, per the common.clj slimming in the core PR):
- NEW v2/queries.clj — the representations-pipeline entry point, portable-dialect
resolution, and the full query-handle store (mint/encode/decode/resolve, the
save-path variant this tool consumes; the execute tools add the minting call
sites in the query PR). Handle tests move alongside as queries_test.
Review finding (round 1, both reviewers): check-metric-shape! rejected only a
PURE-native query (lib/native-only-query?); a native stage nested with an MBQL
stage slipped through to the generic can't-be-saved message (or an incidental
editable? rejection that would open if :native-permissions were ever populated
server-side). Tightened to the whole-tree query-guards/native-query? — the same
guard question_write uses — so any native stage is refused with the metric-
specific message. Regression test added.
--- Round-2 /review-pr response ---
CRITICAL (update! never refreshes result_metadata after a definition swap) REFUTED. Verified in a
fresh JVM: creating a metric then updating it to a different definition (count -> sum with a month
breakout) refreshes result_metadata from a 1-column to a 2-column set. queries/update-card!'s hook
recomputes result_metadata whenever dataset_query changes, so the extraction did not lose the
recompute step. Pinned with metric-write-lifecycle-test asserting the before/after metadata differ.
IMPORTANT fixed: destructiveHint is now true (archived:true trashes the metric; matches the sibling
collection_write tool); the tool no longer emits structuredContent against the stack's text-only
convention; an id on a create call is now rejected rather than silently ignored; collection_position
requires a positive int (matching the downstream contract).
MINOR: applicable fixes applied; the remaining two (a duplicated frontend-url, a kondo allowlist
churn) deferred as cosmetic.
Fresh-JVM: metric-test 25 tests / 79 assertions / 0 failures; kondo 0/0; modules unchanged.
* MCP v2 prep: portable-dialect execution rework in the agent lib
The shared machinery behind every portable-MBQL write path — metabot's
construct_notebook_query today, and the v2 MCP tools (metric_write,
question_write, execute_query) as they land on the stack:
- metabot/tools/construct: execute-representations-query reworked (new
signature; numeric-id source detection; recovery-hint wrapping around
resolution failures).
- NEW metabot/tools/recovery_hints: the standard 'what to call next'
sentences appended to dialect teaching errors.
- agent-lib representations{,.repair}: the corresponding repair/resolve
pipeline changes (resolve.clj needed nothing beyond master's).
Master's post-fork pMBQL→'MBQL 5' doc/message renames are re-applied on
top of the pulled files (wholesale pulls from the foundation would have
reverted them).
Behavior check: master's agent-lib suites (repair_test, resolve_test) and
metabot tool tests run UNCHANGED and green in a fresh JVM.
Review question for this PR: does construct_notebook_query (the existing
metabot consumer) behave identically where not deliberately extended?
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
Review fixes (adversarial review round 1):
- F1: the agent-api /v2/construct-query endpoint and the slackbot query tool
called execute-representations-query without :recovery-hint, so both v1
surfaces relayed bare pipeline errors where slice 03 had baked guidance into
the throw sites. Wired recovery-hints/recovery-hint into both (as the ns
docstring already claims every v1 entry point does). Proven: a URI-in-
source-table error now carries the "use the portable FK" sentence on the
agent-api path.
- F4: node-table-fks never collected a numeric field ref's target table, so it
was never permission-checked; the no-fk-path error then named it from the
un-sandboxed metadata provider — a table-name oracle for any probed id once a
numeric binder lands. Fall back to the bare id for the numeric case (matching
the vector case's own-name behavior). Dark today (no binder on this slice),
fixed before it can go live.
Test fix: the F1 change gave slackbot's execute-representations-query call a
second (:recovery-hint) arg, but slackbot_query_test stubbed the fn with 1-arg
lambdas — the extra arg broke the stubs (nil results). Made the 4 stubs
variadic ([external-query & _]) so they accept the opts map; production change
unchanged. Suite green (53 assertions).
--- Round-2 /review-pr response ---
CRITICAL (numeric field refs bypass the pre-repair permission check) FIXED. A bare numeric field
id in a query node was never resolved to its table, so node-table-fks skipped it and query-check
never gated the field's table. node-table-fks now emits the source-field as a tagged numeric ref;
resolve-table-fk resolves it to the field's table via the permission-aware metadata provider and
checks that table. Two regression tests: one asserts the FK-target table is permission-checked,
one asserts a forbidden FK-target table blocks the call.
CRITICAL (export-source-field-portable writes an un-permission-checked FK path) REFUTED, and the
refutation is closed by the fix above. The exported source-field is the FK-BEARING column, which
by definition lives on the source table — the same table node-table-fks now collects and
query-check gates. Verified in a fresh JVM: the exported field resolves to a column on the source
table, not the FK-target; denying perms on the target raises 403 before export runs. The reviewer
conflated the FK-target table with the FK-bearing column.
IMPORTANT fixed: the recovery_hints ns docstring no longer asserts wiring that does not exist (it
now states the v2 query slice must pass it, and that until then it has no production caller); the
:no-fk-path message and the :unknown-table key collision are corrected. MINOR fixes applied.
Fresh-JVM: construct-representations-test + repair-test 228 tests / 616 assertions / 0 failures;
kondo 0/0; modules unchanged.
* metric_write update: hydrate moderation reviews like PUT /api/card/:id does
`update-card!` un-verifies a verified card whose query changes by reading `:moderation_reviews`
off the card-before. `update!` passed the bare write-check row, so a verified metric kept its
Verified badge after a definition swap through the tool, unlike through REST. The card-before is
now hydrated the way the REST handler hydrates it.
* Pin the create-side permission stack
`check-allowed-to-create-card!` is the only thing stopping a caller saving a metric into a collection
they cannot curate, or over a query they have no data permission to run — `create-card!` performs no
check of its own. Every create test in this namespace runs as an admin, for whom those checks are
vacuous, so deleting the line left the suite green.
Verified fails-when-broken: removing the call fails both branches of this test.
Claude-Session: https://claude.ai/code/session_018PNCQxp9kCypqE3dLQS9va
* Confine this branch's Toucan calls to db namespaces
New policy: t2 is called only from db.clj namespaces. The two source files
this branch adds each held a couple of direct Toucan calls; both move out.
metabase.queries.db takes the Dashboard collection_id lookup and the
:in_dashboards hydration out of card-write-checks. The local
dashboard-id->collection-id alias was pure indirection once the query moved,
so both sites now call the db fn directly.
metabase.mcp.v2.db takes the Card read-back and the moderation-review
hydration out of the metric tool. The comment explaining why the card-before
is hydrated stays at the call site, where the reasoning belongs.
Nothing outside this branch is touched: resolve.clj and redaction.clj came in
on mcpv2-01-core, and metric_test.clj's Toucan calls are read-back assertions
that deliberately bypass the production helpers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tqiaJmaEXSfaWGUZ4LQLv
* Teach the agent about an empty metric definition
`definition: {}` came back as the sanitized "Internal error", which is exactly
what normalize-definition's docstring promises never to happen for a malformed
definition.
The arg schema is an open map, so `{}` validates. normalize-query's first cond
branch short-circuits an empty query to `{}` before its strict validation runs,
so nothing throws there either. `{}` is truthy, so create!'s "pass the metric's
query" fallback never fires. can-save? is the one that finally objects, by way
of a mu/defn input-schema violation the tool can only report as an internal
error.
Guard the normalized result instead. Both create! and update! reach it through
resolve-definition, so one check covers both.
Strict MCP clients are the ones that hit this: they fill every declared
property, so "no definition" arrives as `{}` rather than an absent key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tqiaJmaEXSfaWGUZ4LQLv
* Let a metric_write caller choose the display
The tool hard-coded :display :scalar on create and never wrote it on update,
with no display argument at all. But the shape gate deliberately accepts one
grouping, so `count by month` saved as a metric page rendering a single number,
unfixable through MCP. agent-api's create_metric and update_metric both took an
optional display, so this was a regression rather than a standing limitation.
Accept an optional display on both methods, defaulting to scalar. The accepted
values are enumerated so an LLM-invented display is an argument error instead of
junk on the card.
The enum is a local copy of agent-api's ::card-display rather than a require:
that namespace is the v1 REST surface this tool replaces and GHY-4250 removes,
and pinning v2 to it for seventeen strings buys a dependency we would have to
undo. No canonical list exists to share instead — four other namespaces already
keep their own.
Echo the written display back. It is a detailed-projection key, but the echo is
the only place a caller sees the scalar they never asked for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tqiaJmaEXSfaWGUZ4LQLv
* Fold mcp.v2.db into mcp.db to satisfy the app-DB query linter
CI's `./bin/mage kondo` (full tree) flagged src/metabase/mcp/v2/db.clj:12:
"Application database query calls like `t2/select-one` must live in
metabase[-enterprise].<module>.db namespaces". The rule matches
`metabase.<module>.db`, and `metabase.mcp.v2.db` is a segment deeper, so
the module's db namespace already existed one level up as
`metabase.mcp.db` and this was a second one the rule cannot see.
Moved `hydrate-moderation-reviews` into metabase.mcp.db beside the
existing `hydrate-notification`, repointed metric.clj's two call sites
(`card` folds into the existing `select-one-by-id`), and deleted the
duplicate namespace.
Missed locally because the previous run linted only the files the merge
touched; this file came in with the branch and was never in that set.
Reproduced and fixed against the full-tree run CI uses: 0 errors,
0 warnings. Metric suite 30 tests, 95 assertions green. Modules config
unchanged.
---------
Co-authored-by: Eric Normand <ericwnormand@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Follow the base's contracts after the merge
Three changes the base made to shared namespaces that `question_write` had to
be adapted to; each merged textually clean and broke silently.
`v2.write/readback` dropped its 3-arity in favour of a required `ack-keys`, so
every write threw an ArityException. Passes nil, like collection_write.
`registry/call-tool` now returns `{:result …}`/`{:error …}` rather than raw MCP
content. The test namespace read `:isError`/`:content` off the top level, so its
error-path assertions failed and its success-path assertions passed vacuously. A
local `call-tool` unwraps `:result` and renders a pre-dispatch registry refusal
into the same content shape, which is what the assertions here are written
against.
The `:metabase/t2-query-namespace` rule confines app-db calls to a module's `.db`
namespace, and this file's three `t2/select-one` calls were the tree's only
violations. They go through `mcp.db/select-one-by-id`.
Verified: full `./bin/mage kondo` clean, and the twelve MCP v2 namespaces run
199 tests / 835 assertions green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147UgqNGEjk9nV4jRnt9oS2
* Fix duplicate_content gate laundering and copy-vs-create semantics
`copy-question!` dissoc'd `:document_id` when building the copy. That column
is not placement -- it is the Card read gate. `mi/can-read? :model/Card`
conjoins `parent-document-permits?`, which short-circuits to `true` on a nil
`document_id`, so the copy was adjudicated by collection permissions alone
and disclosed, permanently and to every reader of the destination, material
the Document's content gate exists to withhold. `POST /api/card/:id/copy`
preserves the column and `create-card!`'s `data-keys` allowlist carries it,
so this tool was the only path that laundered the gate away.
Refuse a document-scoped source with a teaching error rather than carrying
the column: `copy-document!` re-parents such cards precisely because their
`collection_id` tracks the document rather than standing alone, so a copy
that kept `document_id` would claim membership in a document whose body
never references it. Neither outcome is the "standalone question in
collection-id" the caller asked for, so name the operation that works.
Also drop `check-allowed-to-create-card!` in favour of `api/create-check` +
`create-card!`, matching REST `/copy` and `clone-card!` (UXW-5037). Copying
is not authoring -- the query comes from a row the caller already passed a
read check on -- and the authoring stack refused a user who could read and
run a native card but lacked native query-building perms. Destination
create-permission is still enforced.
Tool description: drop the per-type create scopes GHY-4225 removed, stop
scoping the `uncopied` report to deep copies (`card->decision` discards
before it consults `deep-copy?`, so shallow copies drop cards too), and
state the document-scoped refusal.
`is_deep_copy` now rejects only an explicit `true` on a non-dashboard;
`false` is the documented default and the strict inputSchema forces clients
to send it. `readback` gets explicit ack-keys `[:type :uncopied_count]` so a
write-only token learns a copy was partial -- `:uncopied` itself must not
survive, since `redact` only redacts *unreadable* cards and an
archived-but-readable one keeps its real name.
Tests: pin the document-gated refusal through `mi/can-read?` as a user who
cannot read the parent document; pin the native-card copy; pin shallow-copy
`uncopied`; pin the degraded key set. Fix two existing tests -- the
"nothing was written" block counted the source collection the copy never
targets, and the destination-permission test asserted a bare `:isError`.
* Check run permission before inferring a card's result metadata
`question_write`'s update path ran the REST check stack before attaching
`:result_metadata`, so `check-update-result-metadata-data-perms` saw a patch
that did not contain the key and never fired. On an update carrying only
`column_metadata`, `check-allowed-to-modify-query` did not fire either — it is
gated on `:dataset_query` changing — so nothing checked data permissions at all
before inference ran over the stored query.
A caller with curate access to the card and no data permission on its query
could therefore annotate its columns, and the inference teaching error named the
query's real columns to them one guess at a time. The write succeeded, storing
the fully inferred metadata.
The create path never had this hole: `check-allowed-to-create-card!` runs the
run-permission check unconditionally before inference. This gives update the
same check, gated on the inference actually happening. It also covers what the
REST endpoint checks on a supplied `result_metadata`, because the overrides only
annotate columns the inference produced — they cannot name a table the query
does not.
`check-allowed-to-run-query!` is the pair `check-allowed-to-create-card!`
already inlined, extracted so both paths call the same thing.
Verified fails-when-broken: removing the call fails both new tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147UgqNGEjk9nV4jRnt9oS2
* Derive protected-resource scopes from the resource path
The bare `/.well-known/oauth-protected-resource` endpoint advertised the v1
canonical resource with the v2 scope set: 6 scopes instead of 22. A client that
probes the bare path -- the behavior BOT-1617 added the endpoint to serve --
read 6, requested those 6, and received a token authorizing nothing on the
resource it had asked about. Empty `tools/list`, and no in-product way to widen
the grant afterwards. Worse, the wrong pairing became durable: the authorization
code grant copies the code's scope onto the access and refresh tokens without
re-consulting the client's registered ceiling, so recovery meant re-consenting
from scratch.
The cause was `protected-resource-metadata` taking the scope set as a separate
`scopes-fn` parameter. Three call sites passed a closure over their own path;
the fourth passed `mcp-resource-scopes` bare, which hit a 0-arity overload
hardcoding the v2 path. Both spellings type-check, and nothing at the call site
says "v2" -- the sibling accessor `mcp-canonical-path` returns the v1 path, so
the two read as interchangeable ways to name the MCP resource.
Rather than correct the fourth call site, drop the parameter: the function now
derives scopes from its own `resource-path`, so the resource and its scope set
cannot disagree. Slice 21 retires v1 and collapses this function; with the
parameter gone it cannot reintroduce the mismatch by overlooking an endpoint.
Delete the 0-arity overload, a delegating arity hiding a non-nil default. Its
only other caller was `default-grant-scopes`, where the union made it a provable
no-op -- `v2-scopes` is a subset of `all-scopes`, verified against a live
instance.
Also on the metadata/narrowing path:
- Delete the `:extra-scopes` plumbing. `registered-opt-in-scopes` read the key
off every registered tool, but `register-tool!`'s closed allowlist rejects it,
so the function provably returned the empty set. Since `register-tool!` runs
at namespace load from a top-level `deftool`, and those namespaces load from
the route table at boot, the first tool to use the documented key would have
taken down the entire v2 MCP surface rather than just that tool. This also
removes the only `mcp.core -> mcp.v2.registry` require.
- Narrow to the union of what every named resource accepts. RFC 8707 permits
several resource indicators and the token must work against each, but the
check asked only whether v2 was named *at all* and then applied that single
answer to the whole request. Naming a v1 alias alongside v2 discarded every
v1-only scope, order-independently -- contradicting the comment two lines
above it and silently shrinking a grant the user then approved.
- Canonicalize resource URIs via `.getAuthority` rather than `.getHost`.
`getHost` is nil for hosts `java.net.URI` considers non-conformant, notably
any containing an underscore -- routine for Docker Compose and internal k8s
service names, and accepted by Metabase's own `u/url?` as a Site URL. On such
an instance no indicator ever matched and narrowing was disabled instance-wide
and silently. Userinfo stripping, host case folding and default-port elision
are preserved.
- Normalize a bare-string `resources` argument. The endpoint schema admits one,
and `keep` over a String iterates characters, none of which canonicalize --
which would skip narrowing entirely. Latent today only because oidc-provider
vectorizes upstream; a security control should not depend on a coercion one
layer away.
- Answer RFC 6749 `invalid_scope` when narrowing removes every requested scope.
`narrow-scope-to-resource` returns nil both for "nothing was requested" and
"nothing survived", and the call site treated them alike -- rendering a consent
screen listing no permissions and minting a zero-scope token, a handshake that
looks successful and yields an empty tool list with no diagnostic.
Tests: assert, for every protected-resource endpoint including the bare one,
that `scopes_supported` equals the scope set for the `:resource` the response
itself advertises -- one invariant covering all four, keyed off the response so
the two cannot drift. The previous tests could not catch this: the bare-path
test omitted `scopes_supported` entirely and the other asserted only
`sequential?`, both of which stay green with the feature deleted. Verified by
reintroducing the defect and confirming the new assertions go red.
Deferred as follow-ups: BOT-2124 (resource indicators are not enforced as a
token audience), BOT-2125 (v2 sub-paths escape narrowing under prefix routing),
BOT-2126 (observability when narrowing declines to apply).
* Make the v2 UI tools' handle resolvable by the iframe
`visualize_query` and `render_drill_through` return `{query_handle}`, but the
iframe bundle that ships on this branch reads `structuredContent.query` and
short-circuits on `if (query)`. With `query` absent the pending tool result was
never stored, `deserializedCard` stayed null, `isReady` never became true, and
the loading spinner never cleared. Both tools were non-functional.
The slice dropped three coordinated pieces that make the handle resolvable.
Ported from `mcp-v2-foundation`:
- `GET /api/embed-mcp/queries/:handle`, returning `{:query :prompt}` behind
`check-session-header!` with an `ms/UUIDString` route param.
- `mcp-ui-parameterized-request-surface` + `on-mcp-ui-request-surface?`.
`mcp-ui-request-surface` matches `[method uri]` by exact string and cannot
express a path parameter, so without this the route 401s at the credential
middleware before the router ever sees it. The regex is UUID-pinned and
anchored with `re-matches`, keeping the entry as narrow as a literal one.
- `fetchQueryByHandle` plus a union `{query?, query_handle?, prompt?}` reader in
`useMcpApp`, guarding out-of-order resolutions with `pendingQueryHandleRef`.
One bundle serves v1 and v2, so both payload shapes stay supported.
Chose foundation's GET form over the POST variant on
`emb-2195-mcp-apps-are-stuck-on-loading-spinner-due-to-claude-sandbox`
(`79b792254cb`). The POST form needs no middleware change, but foundation's ships
the resolver, typed error messages, and the out-of-order guard already written.
`onAuthenticated` now receives the credential rather than reading it from state:
`setUiCredential` has not committed when the callback fires, so resolving from
the closure would use the previous (empty) credential and 401.
`queryError` is threaded to the route so a handle that cannot resolve renders an
error instead of spinning forever.
Docstring corrections. Three sites asserted the resolution endpoint existed in
the present tense; that is now true, and they name the route. The claim that the
store read is "keyed on `(mcp session, user)`" was false: `find-handle-row`
joins `core_session` and filters on `user_id` alone, and the looseness is
deliberate so a client rotating its MCP session keeps its handles. Verified live
against a running instance: cross-user resolution 404s, cross-session for the
same user 200s. The real native-SQL boundary is `check-mcp-ui-native-query!` on
`/api/dataset` via `+refuse-unscoped-native-sql`, which the file never named.
Fixed a dangling wikilink to a var that does not exist.
Dropped `:idempotentHint true` from `visualize_query`; the fresh-`query` path
inserts a handle row per call. `render_drill_through` keeps it.
Removed the `display` argument and output key. It was enum-validated and echoed
but no renderer read it, and `mcp_query_handle` has no column for it, so on the
documented preferred path (mint by `query`, re-visualize by `query_handle`) the
chart type was lost regardless. Rather than advertise a knob that does nothing,
it comes back end to end in GHY-4451.
Reverted the `all-scopes` rewrite. It was set-identical to the base
(`OLD-ONLY ()`, `NEW-ONLY ()`, 22 = 22) and deleted a correct sentence, while
the PR body claimed a scope-advertisement fix that never existed.
Tests. `visualize_test.clj` positively pinned the broken shape, asserting
`(= #{:query_handle} (set (keys body)))` — one side of a two-sided contract, and
green while the widget spun. It now asserts the absence of query data instead,
and two new tests walk the whole loop: mint a handle, call the tool, resolve the
returned handle over the credential-authenticated route. Deleting the endpoint
fails 9 tests; emptying the allowlist entry fails 6. The frontend spec covered
only v1's inline shape, so three tests now cover the handle path; two of them
fail against the previous hook.
Follow-ups filed: GHY-4450 (tools return success for handles the iframe will be
403'd on), GHY-4451 (restore `display` end to end), GHY-4452 (error-message,
schema and coverage cleanups), GHY-4453 (`prompt` dropped on the handle branch).
* Collapse an unreadable dashboard into the same not-found as a missing one
`resolve-dashboard!` read the row with no read check and threw the collapsed
not-found only when the row was absent. A dashboard that existed in a collection
the caller could not curate was refused later, by the write check, with a 403 —
distinguishable from the 404 a nonexistent id gave. A token scoped to one
collection could enumerate every dashboard id on the instance by watching one
against the other.
Resolving through `v2.resolve/resolve-and-read` gives the argument the read
check the rest of the module's id arguments have, so both cases read alike. The
write check still decides whether a card may be saved into the dashboard. This
also subsumes the hand-rolled existence check: `resolve-and-read` turns a
numeric id matching no row into the same not-found, which is what kept a bad id
from reaching the DB as a raw FK violation.
`update-move-into-dashboard-requires-destination-write-test` revoked every
permission on the destination, so under the read check it would now pass on
not-found and stop proving what it was written to prove — that the write
requirement on the move still runs. It now grants read and withholds write, so
the write check is again what refuses.
Verified fails-when-broken: restoring the unchecked read fails the new test.
Closes GHY-4443.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147UgqNGEjk9nV4jRnt9oS2
* Clear the five kondo warnings failing CI on this stack
`./bin/mage kondo` over the whole repo exits 2 on five warnings, so the
backend-tests / Clj-Kondo job fails. Both files are byte-identical to this
slice's parent commit -- the warnings arrive with earlier slices in the stack
and fail the same way there. Fixing here so slice 20 can go green.
- `browse_test.clj`: the `deep` collection is created for its side effect only,
since the assertions look it up by name rather than through the binding.
Renamed `_deep`.
- `question_test.clj`: `check-semantic-type!` is flagged as destructive inside a
`^:parallel` test purely on the `!` suffix. It parses a keyword, throws on a
bad one and returns it -- no state touched -- so the suffix means "may throw",
not "mutates". Added to the `:parallel/safe` whitelist the linter's own
message points at.
* Gate a native query_handle like the other two query sources
The `query_handle` source stored whatever query the handle resolved to without
consulting either SQL gate, so a token holding only `agent:content:write` could
save a card containing raw SQL, on create and on update, with the
`mcp-execute-sql-enabled` kill switch turned off. A later `run_saved_question`
then executes it — `execute_sql` rebuilt without its scope or its switch, which
is what `check-execute-sql-enabled!` exists to prevent.
The exemption rested on the premise that minting a handle already spent those
gates. It doesn't. `construct_native_query` mints under `agent:sql:construct`
and never consults the kill switch, and `/api/embed-mcp/drills` mints from an
uninspected caller-supplied string. Nor does the handle stay with the credential
that minted it: resolution joins `core_session` on `user_id` alone, the MCP
session id being recorded for telemetry, so any credential of that user can
spend a handle minted by any other. There is no independent handle TTL either —
handles outlive the request by the backing session's lifetime.
Gating on the resolved query's shape, as the inline `query` source already does,
covers all of it without knowing which argument carried the SQL. MBQL handles
are unaffected.
The docstring asserted the premise this commit refutes, so it now records why a
handle is not proof the gates were spent.
`create-question-via-native-query-handle-test` pinned the vulnerable behavior as
intended and is inverted. The update path had no native-handle coverage at all;
it gets both halves, plus the kill switch on the handle route.
Verified fails-when-broken: removing the guard fails 9 assertions across the two
tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147UgqNGEjk9nV4jRnt9oS2
* Pin the invariants two advertised-scope tests only appeared to check
Both tests passed regardless of their input, so they read as coverage of the
advertised scope sets without providing any. Rewritten to assert on the sources
rather than on values the construction already guarantees, and verified by
injecting the fault each is meant to catch.
- `advertised-scopes-are-distinct-test` counted duplicates in the *output* of
functions that build through a `sorted-set`, which is distinct by
construction whatever goes in. It now checks every endpoint path and the v2
surface literal, where a scope declared twice upstream is silently swallowed.
Its docstring also still described unioning "the default grant with the opt-in
scopes", which no longer exist.
- `default-grant-covers-everything-advertised-test` asserted that
`default-grant-scopes` covers the sets it is defined as the union of. It now
iterates `mcp-endpoint-paths`, which covers the per-path arities a client
actually meets -- including the v1 aliases, inside the ceiling today only
because `all-scopes` also feeds `supported-scopes`.
Add `mb-full-client-can-still-authorize-test`. Dropping `mb:full` from the
advertised sets is safe only because oidc-provider validates a requested scope
against the client's own registered `:scopes` and never against the provider's
`:scopes-supported`. Nothing pinned that, so un-advertising a scope would start
rejecting the first-party clients that legitimately hold it, silently.
Soften two docstrings that claimed more than the code delivers:
- `supported-scopes` implied explicit registration gates `mb:full`. Dynamic
registration is unauthenticated and passes a client-supplied `scope` through
unchecked, so omitting the scope here narrows who finds it, not who may ask.
- `narrow-scope-to-resource` read as though naming a resource bound the token to
it. It shapes the consent screen and the stored grant only; `resolve-access-token`
never reads `:resource`, and a client that omits the indicator is not narrowed
at all. Both gaps are inherited; BOT-2124 tracks closing them.
* alert_write: close scope, error-message, and delivery-notice gaps
Six fixes from review of the slice.
Scope: clearing `send_once` turns a single scheduled run into an
unbounded series, the same commitment a new schedule makes, so
`execute-scope-trigger` now gates it on `agent:query:run`. Only an
explicit `false` counts — nested nulls survive the boundary's stripping,
so `send_once: null` stays an omission.
Errors: `fetch-alert` now checks write alongside read, collapsing
unreadable, unwritable, and nonexistent into the one not-found that
GHY-4217 needs. That lets the blanket 403 catch in `update!` go, so
every other 403 — a disallowed email domain, a channel template the
caller can't write — reports its real cause instead of claiming the
alert does not exist. `can-write?` mirrors `can-update?` for the bodies
this tool sends: it never reassigns `creator_id` and refuses `card_id`.
Delivery: `notify-creator-of-delivery-change!` becomes
`notify-caller-of-delivery!` and runs on create too. The notification
API's own "you were added" mail removes the current user, so an
agent-created alert pointed at a third party told the token's owner
nothing.
Subscriptions: `:subscriptions` is a `:multi-row?` nested spec, so
writing one back deletes the rest. Passing `schedule` to an alert with
several is now refused, mirroring the multi-channel handler guard.
Validation: the extracted `update-notification!` validates
`::NotificationApiUpdateInput` rather than relying on callers, which the
one non-endpoint caller did not do.
Reverts the `util/cron.clj` `(or hour 0)` change. `check-schedule!`
requires an hour for weekly and monthly, so it was unreachable here,
while the shared util's other callers — pulse sends, v1 create_alert,
database sync schedules — would have had stored null-hour rows move from
hourly to midnight with no test pinning either behavior.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VyVfcPqrmdSnV4YPXDMcUK
* Check the update's new state before probing the warehouse
`update!` ran `check-target-move!` — which opens a warehouse connection to
resolve `target-table-exists?` — before any permission check on the database
that probe connects to.
The tool's own `api/write-check` covers the transform as stored. The caller's
`definition` then names its own `:database`, and `resolve-source` runs no
permission check of its own, so `source-db` becomes any database id the caller
writes down; `resolve-target` stamps it onto the target, and the probe follows
it. The first and only authorization touching the new database lives inside
`transforms/update-transform!`, on the line after.
So a data analyst holding transforms permission on the transform's own
database — enough to pass `write-check` — could ask "does table X exist in
database B?" for a database they hold nothing on, one call per guess, and
drive outbound connections there. Two further gates were skipped along with
the permission check: `check-database-feature` rejects the audit database, the
sample database, and any database with DB routing enabled, and
`throw-if-db-routing-enabled!`'s own docstring says to call it before any
driver operation.
Run the new state's gates first, which is the ordering the create path already
states in a comment ("Before the target check, which opens a warehouse
connection — a bad argument shouldn't pay for that"). REST's `PUT
/api/transform/:id` has the same order and never connects.
The regression test asserts the ordering directly rather than the outcome: it
redefines `driver/table-exists?` to record whether it ran, since the tool
converts a throwing probe into a generic "Internal error" that an
outcome-based assertion passes on. Watched failing on `probed` with the two
forms swapped, and only that test fails.
Verified in a clean JVM on this branch: transform-test 23 tests / 83
assertions; transforms.crud-test + transforms.models.transform-test 10 tests /
41 assertions; kondo 0/0; cljfmt no-op; fix-modules-config unchanged.
* Normalize a retyped model's display, and let a model be a list
Retyping a question to a model left its `display` alone, where
`PUT /api/card/:id` forces table. A model's display is not inert: the query
processor runs the pivot QP when it is `:pivot`, so a pivot question retyped
through this tool kept returning pivoted data — REST makes that unreachable.
A chart display also drives subscription rendering, dashcard sizing, and the
search display filter.
The earlier review dismissed this as cosmetic because "the model layer renders
as table regardless." It doesn't: nothing normalizes a model's display anywhere
in the render path. The invariant holds only because the UI's own convert action
sets table, and REST's rule catches the rest.
REST leaves an explicit `list` alone, and writing that carve-out showed the tool
could not express `list` at all — so an agent could neither build a list-view
model nor preserve one it was updating, even though the model editor offers
exactly table and list. `list` joins the display enum, and the carve-out honors
it.
Retype-to-model had no MCP coverage at all before this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147UgqNGEjk9nV4jRnt9oS2
* Close the three pre-merge findings from the stack review, and the OSS test failure
Adversarial review over slices 02..21 raised 21 findings against `subscription_write`;
three were called pre-merge, and the PR review found a fourth that reddens the OSS job.
The rest are deferred to a follow-up issue.
**`update!` did its work before anything authorized it.** `mi/can-read?` on a non-alert
Pulse is `(or superuser? creator? recipient?)`, so being on the recipient list alone
passed `fetch-subscription`'s gate — with no permission on the dashboard or its
collection. Everything between that gate and `update-pulse-with-perm-checks!`'s
`write-check` then ran for such a caller: `resolve-parameters` answers an unknown id by
printing the dashboard's complete parameter-id set, `classify` probes user existence and
active status, and the channel-topology errors name the channel count and type. The
write check's own 403 also escaped uncaught, so a recipient could tell subscriptions they
are a target of from ids they know nothing about — the existence oracle the sibling
`alert_write` closes with a named GHY-4217 comment one commit below on this stack.
Gating `fetch-subscription` on `write-check` closes all four: the disclosures never run,
and `resolve-and-read-with` collapses the 403 into the same not-found everyone else gets.
Purely additive — `update-pulse-with-perm-checks!` runs `(api/write-check
(retrieve-pulse id))`, the identical check on the identical object, so no caller who
previously succeeded is now refused.
**A schedule field the schedule type never reads was dropped silently.** `check-schedule!`
enforced only the missing-required direction, so `{schedule_type: "hourly", schedule_hour:
9}` reported success and delivered the dashboard 24 times a day with `schedule_hour` NULL.
`alert_write` refuses the same shapes, and its docstring claims the two tools are kept in
step. Adds `check-ignored-schedule-fields!` over this tool's vocabulary (no
`:schedule_minute`), matching the writer exactly: `schedule_hour` for all but hourly,
`schedule_day` for weekly and monthly, `schedule_frame` for monthly. A filled-in null
stays an omission, and monthly's more specific `mid`/`schedule_day` error still wins.
**Repointing a Slack subscription erased the channel's other stored details.** The email
branch carries `(:details existing)` forward with a comment explaining why; the slack
branch replaced the column outright, so an edit dropped `include_pdf`/`attachment_only`
set in the app and the `channel_id` the sender prefers over the display name. The next
send dropped the PDF, and a channel rename sent it nowhere. Now merges onto the stored
details, and `slack-details` carries the cache entry's id as `:channel_id` so a repoint
overwrites the stale one rather than leaving it to misroute.
**The two EE-only parameter tests could not pass on OSS.** Every `define-premium-feature`
getter is `(and config/ee-available? (has-feature? ...))`, which `with-premium-features`
cannot flip, so both failed on the feature refusal while reporting the feature as set.
Wrapped in `mt/when-ee-evailable`, the pattern `redaction_test` already uses for this.
Each fix is pinned by a test that fails when the fix is reverted, verified by reverting
each one in turn: the write-check test's byte-identical baseline comparison catches a 403
that merely contains "not found", and `update-requires-write-permission-test` is tightened
from `(some? (tool-error ...))` — a tautology once it returns — to the collapsed message,
which is why this defect shipped green.
Verification: 44 tests, 116 assertions on EE and 113 on OSS (the guarded bodies compile
away), both fresh-JVM; `:module mcp` 565 tests / 6096 assertions; kondo and cljfmt clean.
Before this change the OSS run was 2 failures and 1 error.
* fix: add_action must write a virtual_card
The frontend recognizes an action dashcard through
`visualization_settings.virtual_card.display` (`isActionDashCard` in
frontend/src/metabase/actions/utils.ts). `add_action` hand-built its
visualization settings and omitted the wrapper every other virtual op
gets from `virtual-card-settings`, so an MCP-created action dashcard
rendered as an empty card instead of a button, and neither the
ActionSidebar nor DashCardActionsPanel recognized it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPHtStCj5NibqNCV4e1dpp
* fix: add_parameter requires a name
`add_parameter` reused `parameter-fields` wholesale, where `:name` is
optional because `update_parameter` patches properties individually. A
nameless parameter gets no slug, which half-breaks embedding, public
links and URL parameter sync, and `dashboard->resolved-params` requires
a non-blank name — so the tool errored on read-back *after* the write
had committed, leaving the parameter behind.
`:name` now lives on each op rather than in the shared field list:
required on add, optional on update.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPHtStCj5NibqNCV4e1dpp
* fix: move_parameter refuses both index and dashcard_id
The op documents "pass exactly one of `index` or `dashcard_id`", but the
`cond` took the `dashcard_id` branch first, so a caller sending both got
the inline placement and no reorder with no error. `add_link` already
refuses the analogous both/neither case; this makes move_parameter
consistent with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPHtStCj5NibqNCV4e1dpp
* fix: move must not autoplace a dashcard against itself
`placement` took its siblings from the whole tab before the moved row
had been removed, so the card being moved counted as an obstacle to
itself and the search could never return the slot it already held. A
`move` carrying only a dashcard_id — which the schema allows, and which
reads as "leave it where it is" — always displaced the card: a
full-width size_y 4 card alone on a tab jumped from row 0 to row 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPHtStCj5NibqNCV4e1dpp
* Omit content_markdown when a document body has no Markdown form
get_content filled `content_markdown` with `prose-mirror/ast->text` output
when `documents/serialize` threw. That text is a flat space-join of text
nodes: no headings, no `{% card %}` embeds, no `:::` layout containers.
`content_markdown` is the key `document_write` accepts as a whole-body
rewrite, and both tool descriptions route a read-modify-write through it.
An agent reading a document with an unrenderable block, appending a line,
and writing it back replaced the stored AST with a single paragraph and
orphaned every anchored comment thread -- and the tool reported success.
`document_write`'s own `body-projection` faces the identical call and does
the opposite, with the rule in its docstring: "old_str is matched against
this exact text, so text that isn't the serialization is worse than none."
It emits `content_markdown_unavailable`. This mirrors it, including the
`Throwable` catch, so the two document paths agree.
Proven in a REPL against a body holding a heading, a `mysteryBlock` and a
card embed: `serialize` throws, `ast->text` yields "Q3 revenue regional
breakdown", and re-parsing that string returns 1 paragraph in place of 3
blocks -- card embed and heading gone.
Both tool descriptions corrected: `document_write` no longer tells the
agent to read with get_content and rewrite via content_markdown, and
get_content's round-trip promise now names the exception.
The existing fallback test asserted the degraded value was correct; it now
asserts the marker. Reverting the fix fails 5 assertions across the two
tests.
* Route the search tool's app-DB queries through mcp/db.clj
The Clj-Kondo check went red on the merge with three warnings, all in
tools/search.clj:
Application database query calls like `t2/select` must live in
metabase[-enterprise].<module>.db namespaces
The rule arrived with the base merge, and the module had already been routed
through metabase.mcp.db; search.clj was the last file still querying directly.
Its three reads move there as collections-for-read-check,
collection-id->name+location and snippets-by-archived-state, each a direct
Toucan 2 call with no added logic, per that namespace's own contract. The
column scoping the snippet query depends on travels with it: only
id/name/description plus collection_id for the read check, never :content,
which holds the SQL body. search.clj no longer requires toucan2.
snippet-rows-does-not-load-content-test stubs `t2/select` through with-redefs
to assert that column list. It still passes unchanged — with-redefs swaps the
var, and mcp.db resolves it at call time — so the guard survives the move
rather than needing to be rewritten around it.
Verified: targeted kondo 0/0 and a full `./bin/mage kondo` 0/0; cljfmt clean;
v2 search + v2 common + metabot search 81 tests / 329 assertions / 0 failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa
* Fix four correctness defects in the query slice found by adversarial review
Keyset paging and the SQL hint each had a way to return the wrong rows, or no
rows, without saying so. All four are proven in a REPL against the Sample
Database, with a regression test each.
1. `with-total-order` and `next-page-query` dropped the query's `:parameters`.
Both rebuild the query through `lib/prepare-for-serialization`, which strips
`:parameters` as runtime-only — but on an MBQL query they are a real filter
that the QP's parameter middleware ANDs on. `execute_query` runs every query
through `with-total-order` before executing, so whenever the rewrite fired
the QP ran the query unfiltered: a filter for quantity = 2 returned
quantities [2 3 2 6 5], with no error and no truncation flag, and the minted
handle described a different question than the one asked. `execute_sql`
already worked around the identical strip; the MBQL path did not.
2. A caller could forge the private `::keyset` marker. The marker means "this
namespace minted this predicate", and both `without-page-boundary` and
`supersede-previous-keyset` strip clauses carrying it. But a tool's `:query`
is an open [:map] and a namespaced option key rides through JSON decode and
serialization intact, so a caller could stamp its own filter and have it
silently dropped from the saved handle and from every later page — a narrow
visible first page over a wide underlying read. Caller-supplied queries are
now stripped where they enter. The cursor path deliberately keeps the marker:
superseding the previous page's predicate depends on it, and stripping there
stacks a dead filter per page instead.
3. `strip-sql-noise` threw StackOverflowError on an unbalanced quote. The
literal-blanking pattern `'(?:[^']|'')*'` is an alternation under a
quantifier, which java.util.regex compiles to one stack frame per character
consumed. A single unterminated quote made it backtrack across the whole
query. It runs on the `validate_only` path with no execution to gate it, and
StackOverflowError is an Error, so it escaped the handler that turns tool
failures into teaching errors and reached the transport uncaught. Replaced
with the equivalent unrolled loop `'[^']*(?:''[^']*)*'`, verified to produce
identical output on every literal shape and to handle 50k characters.
4. The cursor silently dropped every row whose key column was NULL. A keyset
predicate is all comparisons, and under three-valued logic every term is NULL
— never true — for a row whose key is NULL, so those rows were never served
and the response still reported truncated: false. Ordering ORDERS by DISCOUNT
served 1,915 of 18,760 rows and called the result complete. Nullable keys now
refuse to mint, joining the remap and lossy-value guards; MBQL cannot express
NULLS FIRST/LAST, so refusing is the only driver-independent answer.
The refusal keys on declared nullability AND an observed `fingerprint nil%`
above zero. Declared nullability alone is too blunt to gate on — in a typical
warehouse only primary keys are NOT NULL, so it would have cost the cursor
for nearly every non-PK-ordered query. The fingerprint is a sync-time sample,
so a column that gains its first NULL after the last sync stays cursorable
until the next one; that residual is the price of keeping paging usable.
`:module mcp` 658 tests / 6506 assertions green; kondo and cljfmt clean.
* Refuse a constraints value that names a column
`get_parameter_values` accepted `[:map-of :keyword :any]` for `constraints`, and
chain filtering compiles those values into MBQL. A value shaped like a clause was
therefore compiled as one: `{"_PRICE_": [["field", 49, nil]]}` became a reference
to that column, so the server evaluated `WHERE VENUES.PRICE = VENUES.CATEGORY_ID`
and returned the matching rows.
The caller needs only read access to a dashboard with two filters on one table.
It needs no query permission — `*param-values-query*` runs these lookups elevated
precisely so a caller who can only read a dashboard can still see its filter
values — and the reference reached columns marked `visibility_type: sensitive`,
which exist to be unreachable. A name-shaped reference works without a field id.
Reported by galdre on #81245 with a proof; his repro is the test.
`::constraint-value` now admits only scalars and sequences of scalars, so no
clause shape validates and nothing caller-supplied is ever compiled. Refusing at
the boundary rather than blacklisting known clause forms: a blacklist needs
updating every time MBQL grows a shape.
The ns docstring claimed no caller-supplied query material reaches the warehouse.
That is true again, but only because of this schema, so it now says so.
Verified by reproduction: before the fix both field-reference shapes returned
success where the test expects a refusal; after, all four attack shapes are
refused. The test keeps anti-vacuity controls — the unconstrained call returns
the full list and a literal constraint narrows it — so it cannot pass by failing
to reach the warehouse. Namespace: 86 assertions, 0 failures.
* Pin the constraints refusal against a caller who cannot query
The test I first wrote proved the schema rejects a clause-shaped constraint, but
ran under `with-fixtures`, which grants full data permissions. That misses the
point of the finding: the danger is not the shape, it is that a caller holding
NO query permission could read data through it.
Rebuilt on galdre's repro. The caller now has collection read and
`:perms/view-data :unrestricted` but `:perms/create-queries :no`, and the test
asserts up front that it cannot run a query against VENUES at all — so the
refusals below are about privilege, not validation tidiness. Adds his
sensitive-column case: a reference to a column marked `visibility_type:
sensitive`, which exists to be unreachable.
Verified by mutation: with the schema returned to `:any` this fails 3 assertions
(both field-reference shapes plus the sensitive column); the earlier weaker
version caught only 2. Anti-vacuity controls keep their teeth — the unconstrained
call returns all 100 values, a literal constraint narrows it, and a literal still
works after the fix, so the refusal is shape-based rather than blanket.
* Stop get_content handing a caller data-derived values it cannot reach
Two paths returned values computed from, or describing, rows and cards the
caller was denied. Both were reported in review of #81616.
`include: ["fields"]` returned stored column fingerprints verbatim. A
fingerprint is computed over the whole table: `:min` and `:max` are
individual cell values and `:distinct-count` counts every row. A caller
sandboxed to 10 of 100 rows received statistics over all 100, and a caller
with no data permission on the table at all received the same numbers.
Columns whose table is row-restricted now lose `:fingerprint`, via
`metabot.metadata-perms/row-restricted-table-ids` -- which covers
sandboxing, impersonation and database routing, and fails closed. Nothing
else in the payload is data-derived: names, types, semantic types and field
ids are schema, not rows.
`include: ["layout"]` returned `:visualization_settings` and
`:parameter_mappings` for dashcards whose card had just been redacted as
unreadable. `redact-dashcard` reduced the card to nothing, which is the
whole redaction for the projection, but `dashboard-layout` reads the same
dashcard directly -- so the caller still learned the card's author-written
title, the warehouse columns it renders with their labels, its click
targets, and the field ids its filters are wired to. The redaction now
strips those alongside the card. Layout keys (sizing, container styling)
describe the dashboard, not the card, and survive.
`:parameter_mappings` is emptied rather than removed: it is a required key
on a DashboardCard, and the parameters summary runs
`dashboard->resolved-params` over the same row, which fails its schema when
the key is absent.
The layout case is parity with REST rather than a regression --
`dashboards-rest/api.clj`'s `hide-unreadable-card` leaves the same settings
behind -- but this file already chose to be stricter than REST twice (the
link-entity strip in this very function, and the transform target-table
gate), so it follows that standard.
Tests: the fingerprint rule needs a live sandbox, so it lives in
`metabase-enterprise.mcp.v2.redaction-sandbox-test` alongside the other
sandbox-only redaction; it asserts an admin still sees fingerprints first,
so it cannot pass vacuously, and checks the table really is row-restricted
before trusting the result. The layout rule holds in OSS and is pinned in
`metabase.mcp.v2.redaction-test`. Reverting either fix fails its test.
`:module mcp` 5892 assertions, 0 failures.
* Cover archived reads and not-found parity beyond `question`
Two gaps Eric raised in review of #81616.
`get-content-reads-archived-content-test` pins the read-path behavior for
trashed content, which was projected but never exercised. Archived content
reads rather than 404ing -- an agent asked to restore something has to be
able to read it first -- and reports `archived: true` so it is not mistaken
for live. A live entity in the same test asserts the flag is not constant.
`get-content-not-found-parity-across-types-test` extends the existing oracle
check, which covered only `question`, to model, dashboard, document and
collection. Each type has its own fetch fn, so parity on one proves nothing
about the others; the test asserts the unreadable and nonexistent messages
differ only by the id.
Snippet is deliberately absent from that loop: snippets live in the
`snippets` collection namespace rather than under the restricted collection,
so `with-non-admin-groups-no-collection-perms` cannot make one unreadable.
Its parity needs a snippets-namespace setup of its own -- noted in the test.
Found while writing these, not fixed here: `:archived` is in the question,
collection and document projections but not the dashboard one, so a trashed
dashboard reads back with no archived flag. Filed to GHY-4471 rather than
widening this PR.
`content-test` 39 tests / 213 assertions, 0 failures.
* Name the scope get_parameter_values actually requires
scope-gate-test said the tool requires agent:resource:read. It declares
agent:content:read, and the test's own assertions say so — agent:sql:run is
refused, agent:content:read succeeds. agent:resource:read gates a resource
(catalog://metabase/fields), not this tool.
* fix(mcp): name budget-omitted tables from source rows
`budget-omitted` read `:id`/`:name` off the already-projected payload, but a
`fields` selection that excludes them leaves nothing to read: every omitted
entry serialized as `{"id": null, "name": null}`, so the caller could not tell
which tables were dropped and "request in a separate call" was unactionable.
`assemble-tables` no longer builds `:omitted` — its `:tables` is always a
prefix of `payloads`, which is index-aligned with `rows`, so `get-fields`
names the dropped tables from the source rows instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QkpS1yAdHikDjE1hQaShC
* fix(mcp): withhold fingerprints from row-restricted users
`get_fields`' detailed projection returned each field's `:fingerprint`, which
is computed at sync time across every row of the table. A user whose row
access is narrowed by sandboxing, connection impersonation, or database
routing got correctly narrowed `:values` but a whole-table fingerprint:
`:min`/`:max`/`:earliest`/`:latest` are literal values from rows they cannot
see, and `:global :distinct-count` is whole-table cardinality for every column
type. There is no safe subset, so the whole map is withheld, following
`metabot.tools.field-stats/field-statistics`.
The restriction lookup is batched over the request's tables and fails closed.
The `fields` projection path is covered by the same `detailed?` branch, so a
`fields: ["fields.fingerprint"]` selection cannot route around it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QkpS1yAdHikDjE1hQaShC
* fix(mcp): require query permission to inline field values
`get_fields` gated only on `mi/can-read?` on the table, but then inlined
cached FieldValues, whose own check bottoms out in `can-read?` as well. A
caller holding `manage-table-metadata` alone therefore got values that
`/api/field/:id/values` refuses with a 403 — REST gates `summary` on
`read-check` and `values` on `query-check`, and that split has to hold here
too: values are rows of the table, not metadata about it.
`attach-inline-values` now primes the table perms cache once per request and
restricts the batched fetch to tables the caller can query. Tables it can only
read keep their full field metadata, without `:values`. The gate is per table,
so a mixed batch resolves each one on its own.
`response_format` now documents that values and fingerprints are
permission-dependent and absent rather than empty when withheld.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QkpS1yAdHikDjE1hQaShC
* test(mcp): drive browse_data through the registry
Every browse_data test called the handler directly, so the scope gate, the
closed args schema, and top-level nil-stripping went unexercised — unlike
browse_collection, which already dispatches through `registry/call-tool`.
Covers scope denial and grant (including tools/list visibility), the closed
map's teaching errors for unknown keys, wrong types, out-of-range limits and
bad enum values, the strict-client contract that an all-nulls call equals the
minimal one, the interaction between stripping and contains?-based per-action
validation, and nested nils being caught by the schema rather than reaching
the handler.
No behavior changes: every mechanism already worked. Each assertion group was
verified non-vacuous by perturbing it and confirming it goes red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QkpS1yAdHikDjE1hQaShC
* Build the sandbox test's card query with Lib
mt/mbql-query is deprecated since 0.61.0, and the :deprecated-var warning
fails the Clj-Kondo CI job.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5
* Move get_content's app-db queries into metabase.mcp.db
The module-boundary linter requires t2 calls to live in a <module>.db
namespace, and its warnings fail the Clj-Kondo CI job.
table-by-id deliberately omits select-one-by-id's integer precondition: a
nil table_id would raise an AssertionError, which escapes the catch
Exception that degrades one definition to nil, failing the whole batch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5
* test(mcp): waive the mbql-query deprecation in the sandbox test ns
The full-repo kondo run reported three `#'metabase.test.data/mbql-query is
deprecated` warnings and exited non-zero. `:deprecated-var` needs
whole-project analysis to resolve the potemkin import, so the per-file lints
the file was checked with reported 0/0.
Uses the ns attr-map waiver rather than inline ignores: `:deprecated-var` is
budgeted at 86 in `.clj-kondo/ratchets.edn`, and three more would trade a red
lint for a red `kondo-ratchet-test`. The attr-map is counted by neither ratchet
and is the settled convention for this deprecation in EE test namespaces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QkpS1yAdHikDjE1hQaShC
* [mcp-v2 12] definitions: adapt to the base's shared contracts
The merge from mcpv2-01-core was textually clean but semantically broken
in three ways, none of which appeared as a conflict.
- `v2.write/readback` dropped its 3-arity for a single 4-arity taking
`ack-keys`. All four call sites passed three args, an ArityException at
runtime. Now pass `nil` explicitly, matching metric/collection/question.
- `registry/call-tool` now answers `{:result …}` / `{:error …}` rather
than bare MCP content. The test harness read the old shape, so every
refusal looked like a success and every result read nil — 101 failures.
`call-tool!` now normalizes both into the `{:isError true}` MCP shape,
as `content_test/call-content` already does, leaving the helpers alone.
- `metabase.mcp.db` arrived with the `:metabase/t2-query-namespace` rule
confining Toucan 2 calls to it. Both `t2/select-one :model/Table` calls
become `mcp.db/table-by-id`, whose contract already covers the
nil-tolerant column-value case these callers have.
Also: with the base's dynamic-require hook failing closed, `api-fn` trips
`:metabase/modules`. Added the sanctioned ignore and raised the budget to
24 — the dynamic resolve is deliberate, keeping `mcp` a consumer of the
segments/measures *models* rather than the *modules*.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbCo3ybRitdVSQXYcG97QR
* kick ci
* Pass ack-keys to readback in alert_write
readback takes [token-scopes read-scopes row ack-keys]. The call site passed
three arguments, which fails to compile against the integration branch.
nil matches every other _write tool: ack-keys exempts caller-supplied keys from
the read gate on the degraded path, and alert_write returns only server-derived
state.
* Move alert_write's app-db queries into mcp.db
recipients/classify called t2/exists? and check-writable! called t2/select-one
directly, which the module-boundary linter rejects outside metabase.mcp.db.
notification-by-payload-type already existed for the select-one.
active-user-exists? is new, alongside the existing subscription-pulse-exists?.
* Unwrap the call-tool envelope in alert_test
registry/call-tool returns {:result ...}/{:error ...}. The helpers read :isError
off the bare response, so every expected-error assertion saw a nil :isError and
reported "got success".
Same shape as bookmark_test.clj on the integration branch.
* Revert slice-17 fixes that rode in on the merge
The merge resolution fixed two pre-existing defects in slice 17's files to get
this branch's lint green: `v2.write/readback` called with 3 args where it takes
4 (alert.clj, subscription.clj), and six `:metabase/t2-query-namespace`
warnings. Both belong on `mcpv2-17-query`, and carrying them here guarantees a
conflict when 17 fixes them itself.
Restores the five files to `origin/mcpv2-17-query` byte-for-byte and drops the
four `mcp.db` fns that existed only to serve them. The six browse fns 16 needs
stay.
Lint is now red on this branch with 17's own 2 errors and 6 warnings, in files
identical to 17's. It goes green when 17 does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QkpS1yAdHikDjE1hQaShC
* Give readback its fourth arg, move Toucan calls into mcp.db, and teach the tests call-tool's new shape
Three defects surfaced while merging 17 into 16. 16 patched them locally to get
its own CI green; they belong here.
readback takes [token-scopes read-scopes row ack-keys], but alert_write and
subscription_write passed three, so both threw an ArityException on every create
and update. Ten of the other eleven call sites pass nil for ack-keys; only
bookmark passes a value, because bookmarked is caller-supplied and has to
survive scope degradation. Neither of these returns anything the caller handed
in, so nil is right.
The t2-query-namespace hook wants Toucan query calls in the module's db
namespace. notification-by-payload-type and subscription-pulse-exists? already
existed and already fit; nullable-fields-with-fingerprints, active-user-exists?,
dashboard-parameters and delete-query-handles-created-before! are new. query.clj
and recipients.clj no longer need toucan2.core at all.
call-tool now returns {:error {:code :message}} on a pre-dispatch rejection and
{:result mcp-content} once the handler has run, rather than MCP content
directly. Six test namespaces still read the old shape, which cost 135 failures
in CI: :content came back nil and :isError never fired, so a tool error could
pass as success. The helpers take the whole outcome now and share dispatch-error?
and response-text, mirroring browse_test on 16 so the two agree. scope-gating-test
asserts the denial arrives as {:error {:message ...}}, pinning the behavior change
instead of papering over it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GJCEEY8W7fXmtMHUBS3Mc
* Fix subscription_write against the integration branch
readback takes four args; the call site passed three.
Two app-db queries moved into mcp.db, which the module-boundary linter requires:
subscription-pulse-exists? already existed, dashboard-parameters is new.
Test helpers unwrap the {:result ...}/{:error ...} envelope registry/call-tool
now returns, matching bookmark_test.clj.
* Drop the duplicate active-user-exists?
Slice 15 added one next to notification-by-payload-type; this branch already had
an identical definition. The merge kept both.
* Drop the duplicate active-user-exists?
Slice 15 added one next to notification-by-payload-type; this branch already had
an identical definition. The merge kept both.
* Unwrap the envelope in the surplus-schedule-field test
This case reads the response directly rather than through tool-error, so each
schedule type is asserted independently. It still read the pre-envelope shape,
so :isError was always nil and the refusal looked like a success.
* Fix dashboard_write against the integration branch
Four app-db queries moved into mcp.db, which the module-boundary linter requires:
dashboard.clj reuses the generic select-by-ids/select-one-by-id, subscription.clj
reuses subscription-pulse-exists? and a new dashboard-parameters.
Test helpers unwrap the {:result ...}/{:error ...} envelope registry/call-tool
now returns.
browse.clj still carries app-db calls here; slice 16 has already moved them and
merges first.
* Restore two mcp.db fns dropped in the integration merge
delete-query-handles-created-before! (called by the query-handle GC task) and
nullable-fields-with-fingerprints (called by v2/query.clj) are added by this
slice. Resolving db.clj wholesale toward the integration branch removed them,
so every namespace loading them failed with No such var.
* Stop import-mbql recursing forever on a numeric :source-card
content-ref? matches a portable entity_id or a bare numeric id. import-fk of a
numeric id returns a numeric id, so recurring on the whole match re-entered this
branch on its own output until the stack blew.
The :source-table branch escapes only because it rewrites the value to card__N
and stops matching. Recur on the rest of the map instead, and set :source-card
from the resolved value.
* Stop import-mbql recursing forever on a numeric :source-card
content-ref? matches a portable entity_id or a bare numeric id. import-fk of a
numeric id returns a numeric id, so recurring on the whole match re-entered this
branch on its own output until the stack blew.
The :source-table branch escapes only because it rewrites the value to card__N
and stops matching. Recur on the rest of the map instead, and set :source-card
from the resolved value.
* Stop import-mbql recursing forever on a numeric :source-card
content-ref? matches a portable entity_id or a bare numeric id. import-fk of a
numeric id returns a numeric id, so recurring on the whole match re-entered this
branch on its own output until the stack blew.
* Stop import-mbql recursing forever on a numeric :source-card
content-ref? matches a portable entity_id or a bare numeric id. import-fk of a
numeric id returns a numeric id, so recurring on the whole match re-entered this
branch on its own output until the stack blew.
* Stop import-mbql recursing forever on a numeric :source-card
content-ref? matches a portable entity_id or a bare numeric id. import-fk of a
numeric id returns a numeric id, so recurring on the whole match re-entered this
branch on its own output until the stack blew.
* Stop import-mbql recursing forever on a numeric :source-card
content-ref? matches a portable entity_id or a bare numeric id. import-fk of a
numeric id returns a numeric id, so recurring on the whole match re-entered this
branch on its own output until the stack blew.
* Pass ack-keys to readback in dashboard_write
readback takes [token-scopes read-scopes row ack-keys]; this call site passed
three. nil matches every other _write tool: dashboard_write returns
server-derived entity state, so nothing is exempt from the read gate.
* Unwrap the call-tool envelope in duplicate_test
registry/call-tool returns {:result ...}/{:error ...}; the helpers read :isError
off the bare response, so a refusal read as a success.
* Unwrap the call-tool envelope in visualize_test
registry/call-tool returns {:result ...}/{:error ...}. The helpers read :isError
and :structuredContent off the bare response, so every error assertion saw nil
and the suite reported 10 failures / 14 errors against working code.
Adds dispatch-error?/response-text matching the other v2 tool suites, and
routes the two direct (:isError (call! ...)) reads through it.
* Migrate legacy MCP OAuth clients and tokens onto the v2 scope set
Slice 21 repoints /api/metabase-mcp at the v2 tool surface, which gates every
tool on one of six coarse scopes. Every MCP client connected to a shipped
x.60-x.63 release holds a token carrying the 17 legacy per-entity agent scopes
instead. No legacy scope satisfies any v2 tool scope and registry/list-tools
filters silently, so those clients handshake successfully and then get HTTP 200
with an empty tools list - no error, nothing logged.
It does not self-heal: the refresh grant copies the original scope forward and
can only narrow, and rotation issues a fresh refresh token each time, so a
refreshing client stays broken indefinitely. Manual re-auth fails too, because
/oauth/authorize validates the requested scopes against the client's registered
snapshot before narrowing, returning a 400 invalid_request rendered raw in the
user's browser tab.
Two data-only custom migrations fix both halves:
- WidenDynamicOAuthClientScopesForMcpV2 unions the six v2 scopes into every
dynamically registered client's snapshot. This raises a ceiling, not a grant:
no token gains anything, and the user still consents per-authorization.
- RevokeLegacyMcpOAuthTokens stamps revoked_at on access AND refresh tokens
that satisfy no v2 tool scope and carry a scope outside the six. Both tables
must be revoked or the client refreshes instead of re-authenticating.
Afterwards a client's next request gets the existing invalid_token 401 with the
discovery challenge, re-authenticates against a snapshot that now validates, and
lands with the six scopes - the RFC 6750 path every MCP client already handles.
A 403 insufficient_scope step-up was rejected deliberately: step-up is currently
broken in both Claude Code and Codex, so it would leave clients stuck.
The scope strings are literals rather than a read of mcp.paths/v2-surface-scopes
so the migrations' behaviour is frozen against later edits to that vector. Both
are idempotent and safe to re-run.
Tests: a row-level migration test over the changelog, an end-to-end transport
test that characterizes the empty-tool-list bug and then pins the fixed
behaviour, and an authorize test proving the 400 becomes a consent page.
* Re-apply the browsable-database filter to FK neighbour tables
related_tables filtered only on mi/can-read?, which evaluates per-table perms
against :db_id and has no notion of a router destination: a destination reads as
affirmatively readable because the Database read check redirects to its router.
An FK from a still-router table into a table whose :db_id was reassigned carried
the destination's column names into the response by the neighbour path, which
the directly-requested path already refuses. browsable-db-ids is already computed
for that path; thread it through.
* Make the destination-permission tests reach the create-check
Both tests asserting the destination create-permission refusal wrapped the
destination in `with-non-admin-groups-no-collection-perms`, which revokes
*read* as well as curate. `destination-collection-id` resolves the argument
through `resolve-collection-id`, whose documented behaviour is to collapse
"doesn't exist" and "exists but not readable" into one not-found error so the
argument never reports the existence of a collection the caller cannot see --
so the call was refused there, before `api/create-check` ever ran, and both
tests failed on the tightened `#"(?i)permission"` assertion introduced with
the `check-allowed-to-create-card!` removal.
Grant read on the destination so it is readable but not curatable, which is
the state the create-check exists to refuse. Matches the sibling case in
`collection_test.clj`, which grants read for the same reason and notes that a
not-found collapse there would mean the destination check had stopped running.
* Move app-db queries out of dashboards/write and pulse/broken-subscriptions
The app-db linter requires t2/... calls to live in <module>.db namespaces.
Slice 18's write.clj and broken_subscriptions.clj ran queries inline, which
Kondo flagged once this branch merged with mcpv2-01-core (where both the
linter and the .db namespaces live).
Each moved call is a direct Toucan 2 call in the module's .db namespace with
no added logic; call sites keep identical semantics. Reused existing helpers
where they already covered the query (dashboard, update-dashboard!, card,
card-queries, dashcards-by-ids, insert-dashboard!, insert-dashboard-tabs!).
Also regenerates .clj-kondo/config/modules/config.edn, restoring pulse and
query-permissions to dashboards-rest :uses along with the model imports the
generator derives.
* Ship only the scope-widening half of the MCP v2 OAuth migration
Widening a dynamic client's registered scope snapshot grants nothing: no token
gains a scope, and the user consents to the six on the new screen. It is safe to
run on upgrade.
Revoking the legacy-shaped tokens is the half that forces the re-authentication,
and it logs every connected MCP client out once. That is a release decision, not
a migration detail, so RevokeLegacyMcpOAuthTokens stays written and tested but is
not wired into a changeset. The test pins that no token is revoked yet, so wiring
it up later is a visible change rather than a silent one.
* Revert "Ship only the scope-widening half of the MCP v2 OAuth migration"
This reverts commit 88fec4366f.
* Pass token-scopes to issue-ui-credential in callback_api_test
This slice retires the 2-arity that minted claimless, gate-exempt :legacy
credentials. Four call sites here still used it and threw ArityException.
* Drop the duplicate check-semantic-type! kondo entry
Resolving .clj-kondo/config.edn by unioning both sides of the conflict added an
entry the file already carried further down, which made the EDN unparseable:
Duplicate key: metabase.mcp.v2.tools.question/check-semantic-type!
A broken config takes down Clj-Kondo and kondo-ratchet-test together.
* Take the integration branch's browse_test
Resolving this file's conflict earlier kept a comment from this branch, and with
it the whole pre-slice-16 file. assemble-tables no longer returns :omitted, so 6
failures and 23 errors followed. This branch adds no test the integration branch
lacks.
* Drop opt-in-scopes with the plumbing it called
Slice 20 removed registered-opt-in-scopes from the v2 registry; keeping this
wrapper left a call to a function that no longer exists, and nothing else
referenced it. The v2.registry require goes with it.
* Fix the last v1-retirement fallout: arity, unused require, module config
visualize_test still called the 2-arity issue-ui-credential this slice retires.
usage_test required metabase.mcp.v2.api without using it, and marked a test
^:parallel that calls register-tool! — which mutates the shared registry.
Retiring v1 removes namespaces, so the module boundaries moved; regenerated
.clj-kondo/config/modules/config.edn.
* Point usage_test at v2's behaviour on the retired v1 alias
The bare /api/mcp alias now serves v2. Three assertions still described v1:
- a direct call-tool result read :isError off the bare response, but v2 returns
{:result ...}/{:error ...}
- an unknown tool is a registry rejection ({:error ...}) rather than a handler
result carrying :isError
- DELETE tore the session down and stamped ended_at on v1; v2 answers 405, so
nothing on that path ends the row
The DELETE case is a real gap, not just a test change: record-mcp-session-end!
is now callerless. Filed as GHY-4492 rather than wired up here, since choosing
where a v2 session ends is a design decision.
* Fix unregistered card-type schema keyword in check-card-can-be-saved!
`::queries.schema/card-type` was never registered; the schema is
`::card.type`. Malli threw `invalid-schema` at validation time, which
surfaced as an HTTP 500 where callers expected a 400/403.
The typo predates this merge, but nothing reached it: the REST card
endpoint still carried its own inline copy of this check, so the shared
fn had no live caller. Dropping that duplicate during the merge routed
the endpoint here and exposed it.
* GHY-4400 Give the MCP UI credential a purpose-built API (#82047)
* GHY-4400 Give the MCP UI credential a purpose-built API
`refresh_ui_credential` was a scope-escalation primitive. A token granting
only `agent:query:run` is refused `/api/user/current`, but the UI credential
minted from it was served the full profile — email, login and JWT attributes,
SSO source, group ids. Reproduced against the base commit; `/api/session/
properties` was worse, handing an admin's credential 400 settings keys against
140 for an anonymous caller, including SMTP, Slack, SAML, LDAP and embedding
secret configuration.
Both endpoints were reachable because `mcp-ui-request-surface` was an inventory
of what the embedded app happened to call rather than a decision about what the
credential should reach, and because the middleware stamped
`:token-scopes-checked true` unconditionally — which silently defeated the
`::scope/mcp-ui` stamp it sat next to. The comment claiming this "fails closed"
was wrong; it does now.
- Add `GET /api/embed-mcp/bootstrap`, returning the user and settings the
iframe needs. The user projection is a closed schema, so widening it is an
edit someone makes on purpose. Settings come back at non-admin visibility
whoever holds the credential.
- Replace the allowlist with `[method uri] -> required scope`. Query routes
and drills cost `agent:query:run` — the scope that already bought the iframe,
since the v2 shell resources declare it. `:token-scopes-checked` is set only
when the credential's signed claim covers the route, so a route added without
a scope decision is refused rather than served.
- Drop `/api/user/current` and `/api/session/properties` from the surface, and
seed the RTK caches from bootstrap instead, mirroring what the SSO path in
`embedding-sdk-ee/auth/auth.ts` already does.
The credential still reaches endpoints its minting bearer cannot: all seven
declare no `:scope`, so `ensure-scopes-checked` 403s a scoped bearer on each.
That delta is what the credential is for; what it is not is a place for routes
to accumulate. `request-surface-is-a-reviewed-allowlist-test` pins the set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019r4TtdH2RQy1eGbQ5odnjr
* Record why the seeded caches are safe, and how they could stop being
Verified rather than assumed, against RTK 2.12.0: neither `initiate()` on a
freshly upserted entry issues a request. The SSO path in
`embedding-sdk-ee/auth/auth.ts` runs the same sequence, but its endpoints stay
reachable, so a spurious refetch there would succeed silently — it corroborates
nothing.
The subscription those calls register is also the one way this design breaks. An
invalidation of `current-user` or `session-properties` refetches, and both now
fail — the second one fails open, answering anonymously with public-only
settings rather than erroring.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019r4TtdH2RQy1eGbQ5odnjr
* Build the scope-gate test's query without a deprecated macro
`mt/mbql-query` has been deprecated since 0.61.0, and `mage kondo` exits
non-zero on warnings, so the one call reddened CI. Per-file linting cannot see
it: `:deprecated-var` needs the defining namespace's analysis cache, so the file
reads clean in isolation and only the whole-tree run reports it.
The test needs nothing from that macro beyond a valid non-native query, so
spelling out the map it expands to keeps the scope gate as the only thing
separating the two statuses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019r4TtdH2RQy1eGbQ5odnjr
* Test the MCP iframe against the bootstrap projection, not a full user
Nothing on the frontend would have failed if the projection dropped a field the
render tree needs. The hook spec mocked the API module and seeded `{id: 1}`, so
it stayed green whatever the payload became, and the one MCP spec that mounts a
real `SdkQuestion` seeds a full `createMockUser()`.
- `createMockMcpAppsBootstrapUser` is an object literal typed as
`McpAppsBootstrapUser` rather than cast, so the build breaks if the type loses
a field.
- `createMockMcpAppsBootstrapSettings` picks the keys the backend's
`bootstrap-setting-visibilities` actually allows, derived from the setting
registry rather than guessed. The existing SDK state helpers re-expand
whatever they are given through `createMockSettings`, which would put the
admin-only keys back and make the exercise vacuous.
- `McpQuestionView.unit.spec.tsx` mounts the real question view with the store
seeded only from those fixtures, and asserts a chart actually renders.
What this does and does not catch, measured rather than assumed. Removing
`token-features` from the seeded settings turns the new spec red. Removing
`locale`, `permissions` or `is_superuser` from the user does NOT: every
consumer on the reachable path reads them optionally and defaults, so nothing
observable changes. Those three have compile-time coverage only. Runtime
coverage for them needs assertions on behavior they drive — a non-`en` locale
changing rendered text, a drill menu gated on `can_create_queries` — which is a
different test than this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019r4TtdH2RQy1eGbQ5odnjr
* Stop asserting the UI credential reaches /api/user/current
v1's copy of the surface test still expected the pre-GHY-4400 behavior. It is
the twin of the v2 test already updated on this branch, and the only remaining
place asserting the old contract.
Its credential is minted through v1's `refresh_ui_credential`, so it is
`:legacy` — which makes this the one test pinning what a legacy credential can
still reach, coverage lost when `bootstrap-accepts-ui-credential-test` moved to
the scoped arity.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019r4TtdH2RQy1eGbQ5odnjr
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Sort the malli.util require in collections.db
Clj-Kondo and Cljfmt were both red on this file: kondo reported
`Unsorted namespace: malli.util`, cljfmt reported incorrect formatting. Same
defect from two angles — `c8d1ef7b47c`'s merge resolution placed the branch's
`[metabase.app-db.core]` above master's `[malli.util]`.
Full-tree kondo is clean afterwards, and no other file carries the same
problem.
This does not address the rest of that merge: `collections_rest/api.clj` still
has master's `/root/items` refactor reverted, which is what is failing ~164
backend assertions and 20+ e2e groups.
* Finish master's kebab-case rename on /root/items (GHY-4495)
Master's #79863 renamed the collection-items query params from snake_case to
kebab-case — `pinned_state` -> `pinned-state`, `include_library` ->
`include-library`, and the rest — updating the frontend and tests with it. Merge
`c8d1ef7b47c` applied that rename only PARTIALLY: `/root/items` kept the old
names while its own sibling `/root/items/metadata` had already been renamed, and
the test file was left similarly half-converted.
The endpoint schemas are `:closed true`, so an unrecognised param is ignored
rather than refused. Every filter silently stopped applying: `pinned-state=
is_not_pinned` returned pinned items, `include-available-models=true` returned
no `available_models`, and `collection-type` did not filter. The frontend was
already sending kebab-case, which is why this broke collection browsing broadly
rather than in one place.
Completing the rename is the whole fix:
- collections_rest/api.clj — the 17 occurrences in `/root/items`
- collections_rest/api_test.clj — 7 call sites the merge left snake_case
- collections/children.clj — docstrings still naming the old spelling
collections-rest: 186 tests, 950 assertions, 0 failures / 0 errors.
documents.api.collection-test: 0 failures. Full-tree kondo 0/0, cljfmt clean.
No remaining snake_case call sites anywhere in src/test/enterprise/e2e/frontend.
Note for GHY-4495: master's `children_query.clj` did NOT need restoring and
`collections/children.clj` did NOT need deleting. The branch's extraction (slice
16, for `browse_collection`) is fine as it stands; the failure was the rename,
not the refactor.
* Read ProseMirror node attrs by string key (GHY-4496)
Master changed ProseMirror node `attrs` from keyword keys to string keys and
made it a schema-enforced invariant: `::node.attrs` is a `string-keyed-object`
whose docstring states "every key, declared or not, is a string, so the map
never mixes keyword and string keys". The `mcpv2-01-core` merge (c8d1ef7b47)
took master's string-keyed `prose_mirror.clj` but kept the branch's
keyword-keyed consumers, so every read silently missed.
Silent, not loud: these produce wrong values rather than exceptions, and CI
was quiet about all of it.
- `update-cards-in-ast` never matched, so a card created inside a document kept
its negative placeholder id instead of being rewritten to the real one.
- `markdown/serialize` dropped a code block's language and rendered every
heading as h1, because `(:level attrs)` / `(:language attrs)` read nil off a
stored AST and fell through to their defaults.
- `changed_blocks` came back empty from document_write, since block identity is
keyed on `_id`.
- `get_content` reported `card_id` nil for every card embed, and comment
anchoring resolved no ancestors.
Every AST reaching this code from the app db is string-keyed: `:model/Document`
runs `prose-mirror/normalize-document` as an `:out` transform, and
`string-keyed-object` stringifies attrs keys on decode. Verified by round-tripping
a real Document row -- wrote `{:level 2}`, read back `{"level" 2}`.
Two sites are deliberately asymmetric: `markdown.clj`'s `segment->nodes` and
`container-node` read their own keyword-keyed parse structures and emit
string-keyed prose-mirror nodes. Converting both sides there would have broken
the parse path.
The test files hand hand-written ASTs straight to `parse`/`serialize`, bypassing
normalization, so their fixtures encoded a shape production never produces.
Converting them turned up two further defects in this commit that the source
sweep alone missed: the smartLink fallback serialized an empty label, and
`ast-id->type`/`changed-blocks` still read `:_id`.
Behaviour is unchanged wherever it was already correct -- the diff is 209
insertions against 209 deletions, all key style.
Verified: metabase.documents.markdown-test (352 assertions), plus
prose-mirror-test, search-test, mcp.v2.tools.content-test,
mcp.v2.tools.document-test, comments.render-test, search.ingestion-test --
864 assertions, 0 failures. `metabase.documents.api.document-test` improves
from 58 failures to 9; the remaining 9 (`copy-document-test`) fail identically
on the unpatched branch and are unrelated to attrs.
kondo 0 errors 0 warnings; cljfmt clean.
* Point McpQuestionView's mock import at __support__/state (GHY-4497)
The merge (c8d1ef7b47) left this spec importing `metabase/redux/store/mocks`,
a path master no longer has, failing `frontend-tests / fe-type-check` with
TS2307. Both `createMockEmbedState` and `createMockState` live under
`__support__/state` -- the barrel re-exports `./embed` and `./state` -- which is
where the rest of the tree imports them from (see
`embedding-sdk-bundle/test/server-mocks/sdk-init.ts`).
`bun run type-check-pure` goes from 66 errors to 65: this one, and nothing else
introduced. The other 65 are a missing ClojureScript build in a fresh worktree
(`cljs/*` TS2307) plus pre-existing @types/node mismatches (`Timeout` vs
`number`, `ImportMeta.dirname`), all present before this change and none in
files it touches.
The audit's second item for this ticket -- regenerating
`.clj-kondo/config/modules/config.edn` -- turned out to be a no-op:
`./bin/mage fix-modules-config` reports "config.edn already up to date", and
`metabase.core.modules-test` passes with 4492 assertions. GHY-4495 restored the
`metabase.pulse` `:api` entry the audit found dropped, so the drift was gone by
the time the ordering allowed this to run.
* Declare the MCP keyset marker in the clause options schema
MCP v2's keyset paging stamps a private marker on the filter clauses it mints,
so the next page can supersede its predecessor instead of stacking a dead
predicate beside it. Master's #82196 made `lib.schema.common/::options`
`:closed true` and enumerated every namespaced option key the platform stamps —
Mongo's `join-local`, SQL Server's `optimized-bucketing?`, the add-alias-info and
add-remaps keys. It could not have included this one: MCP v2 lives on this
branch.
So `lib/filter` began rejecting the stamped clause:
Invalid input: [nil #:metabase.mcp.v2.query{:keyset ["disallowed key, got: true"]}]
`next-page-cursor!` treats any failure to build a cursor as "this query cannot be
paged" — correct, since refusing to page is always safe — so the rejection
surfaced as cursors silently never being minted rather than as an error. That is
why it read as a guard refusal: `metabase.mcp.v2.query-test` failed 1 assertion
and errored 8 more with "Query handle not found", all downstream of a nil cursor.
Bisected: passes at `eea6bb9f914` (slice 17's own merge) and `a8024807cbc`,
fails at `c8d1ef7b47c` (the master merge). The merge changed none of
`mcp/v2/query.clj`, `mcp/v2/queries.clj` or `agent_api/query_guards.clj` — it
broke them through Lib.
Registering the key is the step #82196 performed for every other option-stamping
module, not a workaround. `{:optional true}`, so no existing query is affected.
`mcp/v2/query.clj`'s logic is unchanged: the GHY-4142 refusal contract — no
cursor unless gap-free paging is provable — is intact.
Also here:
- `internal-keys-fixture-covers-the-schemas-test` derives the declared key set
from the schema and requires a fixture entry for each, so it broke by design
until the fixture gained one. Adding it also gets the marker covered by the two
stripping tests, which is worth having.
- The `keyset-marker` docstring said the marker "rides in a clause's options map,
which no schema declares" and that undeclared keys would merely be stripped.
Both are now false — they are rejected — so it says what actually holds.
metabase.mcp.v2.query-test 62 assertions, metabase.lib.serialize-test 31, both
0 failures / 0 errors. Full-tree kondo 0/0. `:module query-processor`'s 8
failures are pre-existing, reproduced at a clean checkout of the same HEAD.
* GHY-4498 Accept a numeric id sent as a JSON string (#82294)
Accept a numeric id sent as a JSON string (GHY-4498)
Claude Desktop serializes every value of an `anyOf [integer, string]` param
as a string, so `run_saved_question` received `id: "16211"` and rejected it —
while the advertised schema's string branch says that value is valid. The
model cannot force the JSON type, so the numeric id was unreachable from
those clients and only the entity_id worked.
`normalize-id` coerces strings matching `^[1-9][0-9]*$` to integers, at three
chokepoints that between them cover every id param: `resolve-id-or-404` (the
read tools, and every collection_id/parent_id through `resolve-collection-id`),
`dispatch-write` (the `id` of all ten `_write` tools), and `get_content`'s
per-item fetch, which needs its own because alerts and subscriptions have no
entity_id column and reject a non-integer id before any resolution runs.
The `"root"`/`"trash"` sentinels are matched before coercion, and digit runs
too large for a long stay strings and fail validation like any other non-id.
Schemas are unchanged: the string branch already admitted these values.
The teaching error now names a retry that works — reading "pass a numeric id"
the obvious next move is to resend the same number, which fails identically.
Claude-Session: https://claude.ai/code/session_01EWoDYrBS4e4dAWaDeWLpVV
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Send alert_write's update only the fields the write schema accepts
Master's merge brought a new `notification/schema.clj` and closed every
notification schema, including the `hydrated-notification-schema` behind
`::NotificationApiUpdateInput`. `alert_write`'s update path was passing back the
whole hydrated notification from `get-notification`:
(notification.api/update-notification! id (assoc existing :payload payload))
An open map tolerated the read-only hydration keys. A closed one rejects them:
:creator ["disallowed key"], :id, :created_at, :updated_at
:payload {:id, :created_at, :updated_at, :disable_links, :card ["disallowed key"]}
`->mcp-error-content` sanitizes any non-caller-facing exception to a bare
"Internal error" and logs the original at a level the test runner suppresses, so
every alert_write update — including simply pausing an alert with
`{:active false}` — failed with no usable message. 7 failures / 24 errors.
The body is now built from the write shape rather than trimmed down from the read
shape, so a future hydration key cannot reintroduce this. `:subscriptions` and
`:handlers` still carry their existing rows when untouched, because the update
spec deletes whatever a body omits.
Bisected: passes at `a8024807cbc`, fails at `c8d1ef7b47c` (the master merge).
metabase.mcp.v2.tools.alert-test: 35 tests, 141 assertions, 0 failures / 0 errors.
kondo 0/0, cljfmt clean.
Not fixed here: `mcp.v2.tools.dashboard-test`'s 2 failures / 4 errors are a
DIFFERENT cause and unchanged by this. `metabase.dashboards.db/update-dashboard!`
types its `changes` as `(mut/select-keys ::dashboards.schema/dashboard.update
[:parameters])`, so any update touching `:description` or `:cache_ttl` is refused
— a dashboards-module bug, not a notification one.
* Say in bookmark_content's description that nothing reads a bookmark back
Nine of ten fleet workers, told to confirm a bookmark they had just written,
spent three or more calls establishing that no read path exists: grepping all
22 tool schemas for "bookmark" (it appears only inside `bookmark_content`),
checking `get_content`'s `include` enum, checking `search` for a `bookmarks:`
mode symmetric with its existing `recent:`, and reading the generated fields
catalog. Each concluded correctly that the write's own response is the only
report of bookmark state, but only after spending the calls to prove a
negative.
The absence is a real gap, not a defect to paper over: `search` and
`get_content` would each need the row hydrated with a per-user bookmark flag,
and `metabase.mcp.v2.projections` is by contract pure -- "no app-DB access and
no read of the current user" -- so a `bookmarked` field belongs in the fetch
layer, not the projection. That is a larger change than this one.
What is cheap now is telling the agent, so it stops paying to discover it.
* Say in search's `recent` description that it tracks UI views only
Five of ten fleet workers read content with `get_content`, then called
`search(recent: true)` expecting to see it, and got an empty list. Several
could not tell "these reads are not recorded" from "the recents index has not
caught up yet", and spent a call each establishing which.
Recents come from the view log, which the REST read paths write and the MCP
tools do not. That is the existing behaviour and this commit does not change
it -- an agent's tool calls are arguably not "views" in the sense the sidebar
means. It was simply never written down where the agent would look.
* Match master's response schema on the document copy endpoint
`POST /api/document/:id/copy` was 400ing on its own response:
"Invalid response" {:creator ["should be spelled :creator_id, received: #Instance :model/User ..."]}
The endpoint declared `:- ::documents.schema/document` and returns
`copy-document!`'s result, which is `get-document` — hydrated with `:creator`,
`:can_write`, `:can_delete`, `:can_restore`, `:is_remote_synced`. That was fine
while `::document` was open. The master merge brought its closed version, which
describes the app-DB row only ("every column of `:document`"), so a hydrated
document no longer satisfies it.
Master's own copy endpoint declares `:- [:map [:id ::documents.schema/document.id]]`
— an OPEN map that asserts an id is present and permits the rest. Its test asserts
on `:name`, `:collection_id` and the document AST, so master returns the whole
document too; the schema is permissive rather than descriptive. Adopting that line
verbatim leaves our declaration identical to master's, so there is nothing to
reconcile at the eventual merge.
Considered and rejected: adding a `::document.hydrated` schema. It worked, but put
new surface in a shared module to solve a branch-local problem, and diverged from
master where matching it costs nothing.
metabase.documents.api.document-test: 100 tests, 546 assertions, 0 failures /
0 errors — was 9 failures / 1 error. kondo 0/0, cljfmt clean.
* Let update-dashboard! take the schema its name promises
`dashboards.db/update-dashboard!` typed its `changes` as
`(mut/select-keys ::dashboards.schema/dashboard.update [:parameters])`, so every
attribute except `:parameters` was refused:
Invalid input: {:description ["disallowed key, got: nil"],
:cache_ttl ["disallowed key, got: nil"]}
`::dashboard.update` describes itself as "what an update (or insert) of a
Dashboard accepts", and `insert-dashboard!` directly above takes the whole thing.
Update accepting strictly fewer fields than insert was the anomaly; the narrowing
fit when the only caller wrote nothing else.
This branch added such a caller. `metabase.dashboards.write/update-dashboard!`
(new in `c0ddd5fcd4a`, GHY-4147 — the file does not exist on master) selects
`:name`, `:description`, `:archived`, `:collection_id`, `:cache_ttl`, `:width`,
`:position` and more before delegating here, so every dashboard_write touching an
attribute failed. Not merge damage: `dashboards/db.clj` is identical to master,
and the incompatibility is our new caller meeting master's narrower type.
mcp.v2.tools.dashboard-test: 2 failures / 4 errors -> 1 failure / 0 errors.
:module dashboards 114 assertions, 0 failures. :module dashboards-rest 1020
assertions with one pre-existing failure —
`dashboard-card-query-export-format-no-self-service-test`, an EE
download-permissions 403 — verified by running it with this change reverted.
kondo 0/0, cljfmt clean.
The remaining dashboard-test failure is GHY-4500, a separate bug: our copy of
`check-parameter-mapping-permissions` in `dashboards/write.clj` kept the narrow
`::parameter-mapping` schema that #82196 widened on the REST side, while still
assoc'ing `:dashcard-id`/`:card-id` onto every mapping.
* Declare the mapping keys the permission check is handed (GHY-4500)
Every `dashboard_write` that added or changed a parameter mapping —
`wire_parameter` by `target_tag`, `target_field` or raw `target`, and
`patch_dashcard` with `parameter_mappings` — returned "Internal error". Filters
could not be built through the MCP at all. `validate_only: true` succeeded on the
same ops, since it never reaches the check.
#82196 closed `::parameters.schema/parameter-mapping` to
`{parameter_id, target, card_id}`, and widened the REST copy of
`check-parameter-mapping-permissions` to also allow `:dashcard-id`/`:card-id`.
This branch has a second copy of that function in `dashboards/write.clj` — a file
that does not exist on master — which kept the narrow schema while lines 167/174/
183 still assoc those two keys onto every mapping before calling it:
Invalid input: [{:dashcard-id ["disallowed key, got: 177"],
:card-id ["disallowed key, got: 166"]}]
`mu/defn` throws `::invalid-input`, which carries no `:status-code`, so the MCP
sanitizer reported it as "Internal error" — see GHY-4502.
The schema now matches the REST copy verbatim, since this function is a fork of
it and the two drifting is what caused this.
Dev and test only: `mu.fn/instrument-ns?` skips prod, so schema checks do not run
there. That still covers every developer machine and CI, and the frontend was
never affected because it goes through the REST copy.
mcp.v2.tools.dashboard-test: 26 tests, 65 assertions, 0 failures / 0 errors — was
1 failure. :module dashboards green. kondo 0/0, cljfmt clean.
* Say in search's description that an empty result can be a cold index
A fleet worker searched five times for content that provably existed and got
`{"data":[],"returned":0,"total":0}` every time. Its instance's active search
index held one row where healthy peers held sixty-six -- indexing had not
finished when the worker started. Nothing in the response distinguished that
from a genuine no-match, so the worker concluded the content was absent and
abandoned search for manual browsing, turning a two-call job into about eleven.
The honest fix is for the envelope to carry the index's status, but nothing
readiness-related is exported from `metabase.search.core` today, so the tool
cannot ask. Until it can, this at least tells the agent that an empty result
has two causes and names the tools that do not depend on the index.
* Advertise the v2 scope set on every MCP path now that v1 is retired
`mcp-resource-scopes` branched on the path: `/api/metabase-mcp/v2` got the six
scopes the v2 tool registry gates on, and the other aliases got
`all-scopes` — twenty, including the fourteen per-entity agent-API scopes
(`agent:question:create`, `agent:sql:execute`, …) that v1's tools gated on.
That was right while those paths still served v1: narrowing them to the v2 set
would have told a client to request scopes no v1 tool accepted, leaving it with
an empty `tools/list`.
Retiring v1 (#81708) pointed every path at the same v2 handler and inverted the
bug. The canonical `/api/metabase-mcp` — the path the docs advertise, the one
`.well-known/oauth-protected-resource` names, and the one clients probe — served
v2 tools while advertising fourteen scopes nothing gates on, putting them on a
consent screen for capabilities that no longer exist. The old docstring
anticipated this and said the fn "collapses to the v2 answer" when v1 retires;
this is that collapse.
/api/metabase-mcp 20 scopes -> 6
/api/mcp 20 scopes -> 6
/api/metabase-mcp/v2 6 scopes -> 6 (unchanged)
`path` stays in the signature: RFC 9728 metadata is per-resource, and a future
surface may diverge again.
The tests changed because they pinned the branch itself — "a v1 alias keeps the
scopes v1's tools gate on", and an assertion literally computing
`(= (not= path "/api/metabase-mcp/v2") …)`. They now assert every path answers
the same set, which is the property that will keep holding.
No user-facing path changes: `docs/ai/mcp.md` and `llms-install.md` already
advertise `/api/metabase-mcp`, and `/api/metabase-mcp/v2` stays routed for
client configs written while the surface lived there.
Verified: 726 assertions across oauth-server.core-test, oauth-server.api-test,
mcp.v2.api-test and mcp.transport-test, 0 failures. kondo 0/0, cljfmt clean.
* Name the function when a server-side schema check fails (GHY-4502)
Any exception without a 4xx `:status-code` or an explicit MCP error code became
the two words "Internal error". The real exception was logged server-side, which
an agent cannot read and which on a dev box goes to somebody else's terminal.
That cost real time three times over on this branch alone — alert_write's
hydrated body, dashboard_write's parameter mappings, and the document copy
endpoint were all `mu/defn` schema failures reported as "Internal error", each
needing instrumentation past the sanitizer to see. The ticket records an agent
spending nine failing calls over two turns retrying variations, learning nothing,
because every answer was the same two words.
A `mu/defn` failure now names the function:
Server-side schema check failed in `check-parameter-mapping-permissions`:
[{:dashcard-id ["disallowed key, got: 177"]}]. This is a bug in Metabase, not
something to retry — report it.
The message says it is a bug rather than a caller mistake, so the agent reports
it instead of retrying variations of a call that cannot work.
Deliberately narrower than the ticket asked, on one point. The humanization is
included only for `::invalid-input`, where it describes the caller's OWN argument
echoed back. `me/humanize` embeds offending values ("disallowed key, got: 177"),
so for `::invalid-output` — which describes SERVER-produced data — echoing it
could hand the caller a row they have no right to read. Those name the function
and stop. `:value` is never touched in either case: it holds the whole argument
or return value.
Everything else still redacts: the existing SQL, JDBC and NPE cases in
`error-redaction-test` are unchanged.
The correlation-id half of the ticket is not done here.
mcp.v2.common-test: 13 tests, 91 assertions, 0 failures. :module mcp 3487
assertions with one pre-existing error in `query-test`
(`unknown-caller-key-never-reaches-the-qp-test`, a query-validation teaching
error) — verified by running it with this change reverted. kondo 0/0.
* Roll back the dashboard row when a create's second save fails (GHY-4501)
`dashboard_write` `create` with ops writes the dashboard row, then applies the
ops in a second save. When that second save threw, the row stayed. The tool's own
description tells the agent to "find it by name and finish it with update", so in
the field the agent read the error, called `create` again, and again — leaving a
pile of empty dashboards.
There was already a mitigation: the ops are compiled against a blank dashboard
before anything is written, so a bad OP cannot orphan a row
(`create-with-a-bad-op-writes-nothing-test`). It cannot catch a failure that is
not in the ops. The reported case was a `wire_parameter` create failing inside
`check-parameter-mapping-permissions` — a schema rejection the dry run never
reaches, because it does not perform the permission check.
The two writes now share a transaction, so anything throwing after the row exists
takes the row with it, whatever the reason.
Fixing GHY-4500 removed the reported trigger, but not the window: any post-create
failure had the same effect. The new test forces one directly rather than relying
on a bug to produce it, so the guarantee is pinned independently of whatever
happens to throw next.
mcp.v2.tools.dashboard-test: 27 tests, 67 assertions, 0 failures / 0 errors.
mcp.v2.dashboard-ops-test green. kondo 0/0, cljfmt clean.
* Retire the /api/metabase-mcp/v2 alias everywhere
`/api/metabase-mcp/v2` is where the v2 surface lived while it was behind a flag.
The switchover repointed the canonical path and `/api/mcp` at it, so the alias
had nothing left to do.
Removing it from `endpoint-paths` alone was not safe: the path kept serving
traffic while dropping out of everything derived from that set. In particular
`narrow-scope-to-resources` would no longer match an RFC 8707 indicator naming
it, and that path fails OPEN — an unmatched indicator narrows nothing, so a
client naming `/v2` would have kept `mb:full` where it previously lost it.
Carried through all five places:
- `mcp/paths.clj` — out of `endpoint-paths`; the docstring now names the three
things derived from the set and says what a drifted path costs.
- `mcp/core.clj` — `mcp-v2-path` deleted. It still read `mcp.paths/v2-path`,
which was already gone, so `metabase.mcp.core` would not load at all.
- `api_routes/routes.clj` — the `/v2` sub-route dropped; `/metabase-mcp` is now
a single handler.
- `oauth_server/api/metadata.clj` — the `/v2` RFC 9728 document removed. It
advertised `:resource` as the `/v2` URL, telling clients to send an indicator
nothing would recognise.
- tests — repointed at the canonical path, as the migration comments said the
switchover would ("The final switchover PR repoints this at metabase-mcp").
`oauth_server/core_test` needed real edits rather than a find-and-replace:
`/v2` was one of three paths in the union tests, so swapping the string would
have left them comparing a path against itself and quietly asserting nothing.
mcp.v2.api-test 134 assertions, transport-test 218, oauth core+api 338 — all 0
failures / 0 errors. :module mcp 3485 assertions, one pre-existing error in
query-test. Full-tree kondo 0/0. No reference to the retired path survives
outside one historical note in the paths docstring.
* Apply cljfmt to mcp.v2.tools.search
Whitespace only; the Cljfmt CI job was red on this file.
* Fix MCP Apps dropping bound template-tag values when rendering a handle
`execute_sql` mints a query_handle whose payload is deliberately wider than a dataset
query: the values bound to {{tag}} placeholders ride alongside it under `parameters`,
because `prepare-for-serialization` strips `:parameters` from the query as runtime-only.
`getMcpDeserializedCard` parsed that whole payload into `dataset_query`, so the bound
values ended up nested where nothing reads them, and the run assembled its parameters
from the card instead. Visualizing a handle re-ran the SQL with every {{tag}} unbound.
Split the payload instead: the dataset query becomes the card, and the bound values
become `initialSqlParameters`, keyed by template-tag name. That is the form the SDK
matches against the question's tag-derived parameters (by slug). Putting them on the
card would not work — they carry no `id`, and a card-level `parameters` array
short-circuits the very tag-derived list they need to be matched against.
* Restore the response schema on GET /collection/root/items
The merge from master kept the branch's endpoint body, which predates master adding
`:- ::ItemsResponse`, along with two suppressions that the branch no longer needs: the
kebab-case one went stale when the query params were renamed, and the response-schema
one put the tree one over budget on two ratchets.
* Name the required scope in the scope-denial error
The refusal read "Insufficient scope to call tool: bookmark_content" — the tool
the caller already named, and nothing it could act on. A fleet worker holding a
read-only token recorded that this contradicts the server's own `initialize`
instructions, which promise "a failed call always names its fix", and that it
had no way to learn which scope to request.
Both values are in hand at the branch: it fires on
`(not (mcp.scope/matches? token-scopes (:scope tool)))`, so the required scope
and the held set are bound right there. The message now carries them:
Insufficient scope to call tool: bookmark_content.
Requires agent:content:write; your token holds agent:content:read.
`:scope` may be a set of alternatives ([[mcp.scope/matches?]] accepts either),
which reads as "Requires one of a, b"; `token-scopes` may carry the
`::api.scope/unrestricted` keyword, which is not a scope a caller can request,
so only strings are listed back.
The tests changed because twelve of them pinned the old string exactly. They
now match an anchored prefix, so the tool name each one cares about is still
asserted while the scope detail is free to change; `registry-test` asserts the
full new shape, including the held-scope clause.
Verified: `:module mcp`, 3525 assertions, 0 failures. The one remaining error
(`query-test/unknown-caller-key-never-reaches-the-qp-test`) reproduces
identically on the unpatched tree — it is the closed-options-schema breakage
from the master merge, unrelated to this change.
* Return collection_path from get_content, as search already does
`get_content` returned a bare `collection_id`, so an agent asked where an item
lives had to spend a second call resolving the collection. Two fleet workers hit
this on the same task; one made the extra call and then noted it was redundant,
because `search`'s own rows had carried `collection_path` all along.
The path walk moves to `v2.resolve` so both tools share one implementation
rather than forking the permission handling: ancestors the caller cannot read
are omitted rather than failing the path, matching
[[collection/effective-ancestors]]'s breadcrumb semantics (A > B > C with B
unreadable reads "A/C"). `search` keeps its own batched version — it walks whole
result pages and its two lookups are per page, not per row.
`collection_path` joins `question-enrichment-keys`, the set of projection keys
computed at read time rather than selected as Card columns, so the column-select
paths (`list_models`, browse rows) drop it as they already do for
`query_summary` and `template_tags`.
Verified against a live instance: card 20 returns
`"collection_path":"Examples"`, matching what `search` reports for the same
card, and a root item returns nil rather than an empty string.
* Move the GHY-4313 unknown-key test onto the path the whitelist protects
Master closed `:metabase.lib.schema/query` in #82196, so a fresh `query` carrying an
unknown top-level key is now rejected upstream rather than reaching the QP input
whitelist. The test asserting the key is dropped ran on that path, so it errored on the
rejection before exercising the guard it exists to prove.
The guard is `select-keys` against `query-passthrough-keys`, not the schema, and the
distinction matters: the closed schema covers only the fresh path. A handle's stored
query is checked shallowly (non-empty stages, positive last-stage limit) and the
`/drills` callback stores a caller-supplied query verbatim, so an unknown key really
does reach the execution boundary there. Move the test to the handle path, where the
whitelist is load-bearing, and add a second test recording the fresh path's rejection.
Verified by mutation: replacing the `select-keys` with the raw query turns the handle
test red on `(not (contains? captured :evil))`.
* Test the MCP App parameter fix against the payload execute_sql actually mints
The spec asserted against a legacy `dataset_query` shape that was invented, not observed.
The real serialized query is MBQL 5 — `stages` rather than `dataset_query`, and
`template-tags` as an array rather than a map — captured from `prepare-for-serialization`
in the REPL. The extraction happens to be shape-agnostic, since it reads only
`parameters[].target`, so the tests passed against a fiction and proved nothing about
what the embed receives.
Rebuild them on the real payload and add the case that decides it: drive
`getCardUiParameters` and `getParameterValuesForQuestion` — the helpers
`loadQuestionSdk` uses — and assert the tag-derived parameter resolves to slug `cat` and
takes the bound value. Splitting the payload cleanly is worthless if the halves don't
rejoin there.
The value arrives array-wrapped because `normalizeParameterValue` wraps every string- and
number-typed parameter on the way to the request; that is the shape a filter widget
produces too.
Verified by mutation: making the parameter extraction return {} turns both value-carrying
tests red.
* Cover the MCP App parameter chain through to the request payload
Extends the binding test to the full path `loadQuestionSdk` walks: build the Question
from the card, apply template-tag parameters, derive values from initialSqlParameters,
set them, then assemble request parameters as `runQuestionQuery` does. Asserts the QP
receives the bound value against the target the tag declares.
This is the level the bug lived at, so it is the level worth guarding: the earlier tests
only prove the payload splits, and a clean split is worthless if the halves do not
rejoin.
* Correct the keyset-marker stripping claim in resolve-input
The comment said caller-supplied queries are stripped in "the `:query` branch above and
the `/drills` callback". `strip-caller-keyset-markers` has exactly one call site, the
`:query` branch — `POST /api/embed-mcp/drills` stores its caller-supplied query verbatim.
Documenting a guard that isn't there is worse than documenting the gap, so say what the
code does and why the gap is tolerated: reaching it needs the scoped UI credential, and
the effect is confined to the caller's own narrowed-question reporting.
* Fix Duplicate key crash in document collection-items tests
`#{normal-card-id dash-id in-document-card-id}` is a runtime set, and
`PersistentHashSet/createWithCheck` throws on duplicates rather than deduping. Card and
Dashboard ids come from separate sequences, so in a fresh app DB where this namespace is
the first to create both, each gets id 1 and the literal blows up before any assertion
runs. It survived full-suite runs, where thousands of preceding rows keep the sequences
apart, and failed only under `:only`.
Match on (id, model) pairs instead. The vectors differ by model string, so no duplicate is
possible, and it also closes the correctness hole the bare id set had — an unrelated
Dashboard sharing a Card's id would have been pulled into the filtered set.
Applied to the latent third instance in the trash-items test too, which spans Document,
Card, and Dashboard sequences and was one id alignment from the same crash.
metabase.documents.api.collection-test: 11 tests, 66 assertions, 0 failures (was 64 pass,
2 errors).
* GHY-4510 Withhold query_summary when a source card is unreadable
get_content on a card the caller can read rendered `query_summary` with
`lib/describe-query` over `application-database-metadata-provider`, which applies no
per-user read check. For a wrapper whose query sources a restricted card, that resolved
and printed the restricted card's display name — the one read surface that leaked it,
while the direct read 403s, the run path refuses via `check-card-read-perms`, and search
filters it out.
Gate the summary on the caller being able to read every card the query reads, at any
depth (`all-source-card-ids-recursive`, so a transitively nested source counts too).
Fails closed: a missing source card, or a query that can't be walked, withholds the
summary rather than rendering from metadata we cannot account for.
question, model, and metric all resolve through `fetch-card`, so one gate covers the
three types the report named.
The test drives the report's attack — restricted card in a revoked collection, readable
wrapper sourcing it — and asserts the name appears nowhere in the response, not merely
that `query_summary` is nil. Verified failing first: `query_summary` came back as the
restricted card's name verbatim.
metabase.mcp.v2.tools.content-test: 41 tests, 224 assertions, 0 failures.
* Placeholder the table and field ids in execute_query's examples
The description stated `ORDERS = table 5` and `CREATED_AT = field 42` as fact.
On a stock Sample Database ORDERS is table 2, and table 5 is FEEDBACK — a real
table — so an agent copying the example queries the wrong table and gets a
plausible answer with no error anywhere in the trace.
A fleet worker did exactly that: `execute_query {stages:[{source-table: 5,
limit: 5}]}` returned FEEDBACK's columns (ID, ACCOUNT_ID, EMAIL,
DATE_RECEIVED, RATING, ...). Nineteen of twenty-five workers on one case
flagged the discrepancy and looked the id up instead; the ones who did not
would have had no signal.
Correcting the numbers would not fix it. The ids are per-instance, and the
fleet saw 5 resolve to FEEDBACK on most instances and REVIEWS on another from
the same seed data — so any literal id in this docstring is wrong somewhere.
`<TABLE_ID>` and `<FIELD_ID>` cannot be pasted.
The same description already said "never invent or guess ids — discover ids
first", which its own examples contradicted.
* Decouple the MCP App parameter test from getParameterValuesForQuestion
CI type-checks the PR merged with master, and master changed that helper's signature from
{card, metadata, queryParams} to {card, queryParams, buildQuestion}. The spec compiled on
the branch and failed on the merge — a semantic conflict, clean to merge and broken to
type-check.
Call `getParameterValuesByIdFromQueryParams` against `question.parameters()` instead. That
is precisely what the helper delegates to on both sides — the old form passed
`getCardUiParameters(card, metadata)`, the new one passes `buildQuestion(card).parameters()`,
and `Question.parameters()` is `getCardUiParameters` over the same card — so the test
asserts the same semantics while no longer depending on a signature in flux. Both
primitives it now uses are byte-identical on master.
8 tests green; type-check, eslint, and oxfmt clean.
* GHY-4511 Make a question's visualization_settings readable (#82355)
* GHY-4511 Make a question's visualization_settings readable
A chart's settings could not be read back: not in concise, not in detailed,
not through `fields`, and `question_write` did not echo them either. An agent
could write a goal line or a series binding and had no way to check it.
- `get_content` gains a `settings` include for question/model, returning the
stored `visualization_settings` verbatim ({} when nothing is stored).
- `visualization_settings` joins the question detailed projection, which also
makes it a valid `fields` path. It stays one whole-blob path: its own keys
contain literal dots, so nothing deeper could ever be addressed.
- `question_write` echoes `visualization_settings` in its response.
- A `fields` path with no stored value now answers null instead of dropping
out, so an unset `cache_ttl` no longer looks like an unreadable field. A
path through a missing parent still drops — a null-valued shell would claim
a parent that isn't there.
- The visualization-settings skill's "To confirm" section now describes what
ships.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUQL4KAPYRe7DJbwRF4dTy
* Name the include after the property it returns
Review: `include: ["settings"]` returned `visualization_settings`, so the
section name and the property name had to be translated by anyone reading
either. `visualization_settings` is also the name every other surface uses —
the REST API, question_write's args, the card model, the skill — so it is the
one a model reaches for unprompted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUQL4KAPYRe7DJbwRF4dTy
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* GHY-4512 Read a link card's entity back behind a read check (#82357)
`add_link` with `entity` saved correctly all along. `get_content`'s `layout` include stripped `:entity` from every link card on the way out, so an entity link read back as `link: {}` — indistinguishable from an empty card, and impossible to round-trip through `patch_dashcard`. The strip was there to stop the `name`/`description` cached beside the reference at write time from reaching a caller no read check stands behind.
`dashboard-layout` now hydrates `:dashcard/linkcard-info`, the same batched hydration the REST dashboard endpoint runs, and returns the bare `{model, id}` reference — the shape `patch_dashcard` takes back verbatim — when the caller can read the target, and `{restricted: true}` when they cannot. The cached name and description still never leave the server.
Claude-Session: https://claude.ai/code/session_019aHyUCgLuS6QzkcRcZDcdd
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* GHY-4516 Let collection_write create transform folders (#82356)
browse_collection browses the transforms namespace and transform_write
files a transform into a collection_id, but collection_write's namespace
enum was "snippets" only — so the folders those two tools talk about
could not be made through MCP at all.
Update needed no change: namespace is already create-only, a move stays
inside the row's own namespace, and parent_id "root" lands at the root of
the transforms tree rather than the content root. Tests pin that.
Claude-Session: https://claude.ai/code/session_015dqtq17qCpVgMeRvu9uPWt
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* GHY-4517 Honor `name` on measure clauses (#82358)
The measures middleware substitutes a measure's underlying aggregation for
the `:measure` clause, carrying `:lib/uuid` and `:display-name` across but
dropping `:name`. A caller-supplied `{"name": "revenue"}` passed validation
and was then silently discarded, so the output column came back as the
underlying aggregation's name (`sum`, `count`) and a next-stage
`["field", {}, "revenue"]` failed with a raw database error.
The metrics middleware already preserves `:name`; a regression test now
covers that too. Also documents in the query-dialect skill what a metric's
or measure's output column is called.
Claude-Session: https://claude.ai/code/session_01Egi1Dqi8XGihQzQ5BmQYat
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Keep the MCP Apps iframe working when an endpoint declares a scope (#82361)
* Let enforce-scope defer to the MCP Apps UI route gate
An MCP Apps UI credential is not an OAuth token, and an endpoint's declared `:scope` is not
the vocabulary that confines it. `metabase.mcp.ui-surface/request-surface` is — a handful of
routes, each priced in the MCP scope the minting session had to hold — and it is strictly
narrower than any endpoint scope could be.
Those two mechanisms currently refuse each other. The credential carries `::mcp-ui`, which
satisfies no declared `:scope` by design, so declaring one on any route of that surface 403s
the iframe while every other caller is unaffected: an ordinary session carries
`::unrestricted` and passes. It merges clean and fails silently — the iframe stops rendering.
`ensure-scopes-checked` already honours `:token-scopes-checked` as "an authorization decision
already happened upstream". `enforce-scope` did not, and that asymmetry is the bug. The MCP UI
stamp is set only when the surface's own scope gate passes, so honouring it defers to a
route-specific decision rather than waiving one.
Conditioned on `::mcp-ui`, not on the stamp alone: `enforce-scope` sets the stamp itself on
success, so trusting it unconditionally would make every per-endpoint `:scope` a no-op
underneath a namespace-level `enforce-scope`. The test asserts that non-leak directly.
* Guard every MCP UI surface route against the endpoint scope layer
Drives each route on `request-surface` with a real UI credential through the real stack and
asserts the response is not a scope rejection — neither `unsupported_scope` from
`enforce-scope` nor `scope_not_permitted` from `ensure-scopes-checked`.
Asserting on the error codes rather than on a status or a scope name is what makes this
independent of whichever scope a future branch declares, and of what the handler then does
with a deliberately thin probe body: a 400 is a pass, because the request reached the handler.
A coverage assertion pins the probe set to the surface, so adding a route without a probe
fails rather than silently skipping the check.
Verified by mutation, both directions: declaring a `:scope` on POST /api/dataset turns this
red on exactly that route with the carve-out removed, and green with it in place.
* drop v2 ping tool and update llm-install (#82362)
* update llm-install
* drop v2 ping
* Drop the require comments and redundant keep-me around test_echo
* GHY-4518 Summarize a native question by its SQL, and name its database (#82363)
`query_summary` delegates to `lib/describe-query`, whose native-stage display
name is the placeholder "Native query" — enough in the UI, where the SQL renders
beside it, and nothing at all to a caller holding only the row. An audit of an
all-native dashboard had to guess each card's purpose from its description.
Native cards now summarize as the head of their own query text: whitespace
collapsed to one line, capped at 300 characters. The full query stays behind
`include: ["definition"]`, so the summary can stay a summary. A non-string
native query — Mongo stores a map — keeps the generic Lib description rather
than rendering a map.
Both branches stay behind the `source-cards-readable?` gate from GHY-4510: a
native query names a Card in a `{{#123}}` template tag as readily as an MBQL
stage names one in `:source-card`, and `all-source-card-ids` covers both.
The row also carries `database_name` now, read off the metadata provider it
already builds. Resolving `database_id` to a name previously cost a second
`search` call. It is flat rather than nested to match the vocabulary `search`
rows already use, and it joins `question-enrichment-keys` so the column-select
paths drop it instead of selecting a column that does not exist.
Claude-Session: https://claude.ai/code/session_01GXJcdTASE4jxhmE4fzRNkw
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* GHY-4519 Answer a date parameter with its range, not hundreds of dates (#82366)
`get_parameter_values` returned every distinct date in the column for a
`date/*` parameter — 721 on one dashboard, 100 per page — when what a caller
needs is the span and the grammar it can filter with.
A column-backed date parameter now answers:
{"kind": "date", "min": "2016-04-30", "max": "2020-04-19",
"distinct_dates": 18760,
"accepts": ["YYYY-MM-DD", "YYYY-MM-DD~YYYY-MM-DD", "past30days", "thisyear"]}
The range is aggregated rather than folded out of the fetched values, because
listing stops at `result-limit` and values come back ascending: the list's last
value is the 1000th-earliest, not the column's max. On orders.created_at that
gap is fourteen months — the list ends at 2019-02-22 against a real max of
2020-04-19. `chain-filter-range` builds the same source table, joins and
constraint filters the values query builds, aggregated to one row, so
constraints narrow the range exactly as they narrow a list.
Excluded: a date parameter with a static-list or card source, whose dates are a
curated set to pick from rather than a span to write a value inside.
`query` on a date parameter is now a teaching error. A range is two dates and a
grammar, so there is no list to search, and applying it silently would hand back
the whole span as though it had been narrowed.
Claude-Session: https://claude.ai/code/session_01GR7MMvsg2kH4dE24YyX1kS
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Address review nits in useMcpApp
- Drop the unjustified cast and FIXME on window.metabaseConfig; the global
Window.metabaseConfig declaration already types instanceUrl.
- Remove the redundant void operator on the async IIFE.
- Remove the redundant JSDoc on McpAppState.queryError.
* Address review: drop the unreachable v1 payload path, trim comments
Review feedback from sanex3339 on #81245.
Two of the comments were questions — whether the iframe can still receive a v1 response, and
whether the v1 field needs describing, given FE and BE ship from one instance. The answer is
that it cannot, and more strongly than the questions assume: v1 was deleted from this branch
in 84c334c6d2, and both v2 UI tools put `{query_handle}` in `structuredContent` and nothing
else (their output schemas declare only that key). So the inline `query` field, the branch
that read it, and the comments explaining both are dead. Removed.
That removal surfaced why the dead path survived: 8 tests drove the v1 inline shape, so the
suite exercised a payload the backend cannot produce. The fixtures now carry handles — the
real shape — and resolve through the mocked callback API. One test asserted the dead path
outright ("still renders v1's inline query") and is gone with it.
Also replaced the `// Unjustified type cast. FIXME` on `structuredContent` with a runtime
narrowing that checks each field instead of asserting the whole shape, and shortened the
comments flagged across the four other files. Kept the two that carry a hazard rather than a
restatement: the RTK subscription warning in `useMcpUserAndSettingsFetch`, and why
`McpQuestionView`'s spec must not use `mockSettings`.
20 suites, 68 tests green; type-check, eslint, oxfmt clean.
---------
Co-authored-by: Eric Normand <ericwnormand@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Aleksandr Lesnenko <alxnddr@gmail.com>
Co-authored-by: Aleksandr Lesnenko <alxnddr@users.noreply.github.com>
Audit a permission refusal only when someone reaches for something themselves,
the model naming an ID or an ID inside a query the client sent us. Refusals hit
while rendering a query one of our own tools wrote stay quiet, so the log keeps
only the ones that are a real access attempt.
Show a query only if you could run it, using the query processor's own check
rather than read access to the database, and check every table and column the
rendering would name so column sandboxes are caught too.
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
Fixes https://github.com/metabase/metabase/issues/65493
If you have a `details.schema` property set in your connection (this was
removed in v42 in favor of schema filters), you can't change it in the
UI in the current Metabase version. If you wanted to specify a schema
through the "Additional JDBC connection string options", the old
buried schema property would take precedence anyway.
This makes it so the deprecated field is removed when the schema is
set in additional-options.
I've verified this using a real connection and querying `SELECT current_schema()`
as suggested in the bug report.
The test read `:future-ref` straight off the registry entry as soon as the
fetch signalled it had started. But `detached-fetch!` registers the entry
before the fetch begins and only stores the future just after submitting it,
so the thunk can deliver `started` before the `reset!` lands. When it does,
`stalled-future` is nil and `future-cancelled?` throws an NPE.
Under 300 spinning threads a probe caught the window 1-4 times per 20000
fetches; inserting a 5ms sleep between the `future` and the `reset!` makes
the old test fail every run.
Poll for the future instead. Only the test is affected: the sweep acts on
entries older than *fetch-max-age-ms* (an hour), so it can never observe one
whose `:future-ref` is still unset.
Claude-Session: https://claude.ai/code/session_01J1yFPpYiWH2ik8LB9rs7BT
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* multiple jvms per runner for bigquery
* trigger CI
* add failing test
* lower timeout
* remove debug stuff
* clean up comments
---------
Co-authored-by: Claude <noreply@anthropic.com>
* BigQuery: use calendar days for datetimeDiff day unit
Fixes#82193
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Avoid :raw for date part in BigQuery day diff
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* clean up
* add timezone to test
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* add saved question extraction prototype
* fix additional failure cases
* refactor: reconciler
* revert me: manual skill file edit for source swapping
* set data permissions to view for databases referenced in the query
* rename data app collection and group
* prevent agents from removing savedQuestionSourceId
* docs: clarify data app query location
* chore: cleanup query-sync canonical module
* refactor: split query tests into different files
* Swap query source for data app query hooks and apply dynamic filters on top of it (#79584)
* code improvements and bug-fixes for data apps permissions
* Add data apps model imports
* address reviews on data apps permissions
* run sync queries before vite build
* Add data app resource link (#79878)
add button to view data app resources
* Show permission error when users cannot access a data app (#79874)
show permission error page when user do not have permission to view data apps
* Add data app group link in overflow menu (#79873)
Add data app group link
* Store and reconcile data app entity ids in data_app.yaml (#79833)
* Store data app resource entity IDs
* make entity ids in data_app.yaml authoritative
* add top-level value after path key
* simplify data_app.yaml manifest parser
* only allow changing data app collection if empty
* consolidate backend test helpers and remove redundant tests
* chore: restore README changes
* ci: fix failing tests
* Add support of actions to Data App reconciler (#79842)
Add support of actions to Data App reconciler
* Fix bugs/inconsistency and add more e2e for data apps reconciler/syncing (#80002)
* Update semantic schema skill about preventing segments and measures in dynamic parts of a query in Data Apps (#80288)
* Grant view data access on a table-by-table basis in data app groups (#80189)
* grant view data on table instead of database level
* refactor table scan in reconciler
* update e2e tests for permission grants
* Grant data app view permissions per table
* Skip unchanged data app permissions
* add e2e data app test for sandboxing
* reset host app sources after runs
* Preserve sandboxing for data app users (#80406)
* add e2e data app test for sandboxing
* improve e2e tests for data apps
* Remove sandbox E2E from table permissions change
* preserve existing sandboxing from sandboxed groups in data apps
* assert that we grant non-sandboxed tables
* Explain data app sandbox exclusion
* Add data app sandbox E2E coverage
* Exercise sandbox preservation in data app E2E
* Disable saved question sources in data apps (#80139)
* Disable saved question sources in data apps
* Preserve question-only schema scope
* Remove the saved question schema parameter
* Copy table-sourced metrics into data app collection and do not codegen card-sourced metrics (#80086)
* copy table-sourced metrics into the data app collections
* copy table-sourced metrics into the data app collections
* Address metric sync review feedback
* Accept keyword metric display in query response
* Fix data app metric reconciliation ordering
* Remove old lockfile format
* exclude card-sourced metrics from schema generator
* do not use card terms in metrics to avoid ambiguity with card-sourced metrics
* fix model imports
* Fix data app metric reconciliation
* refactor: cleanup reconciler unit tests
* Fix duplicated metric client helper
* add safe metrics path
* Handle unsupported metric dependencies
* Fix data app CI test regressions
* Fix "Re-adding removed query fails to adopt deleted card" (#80341)
* Clean-up data apps permissions endpoints (#80825)
* Remove entity ids from data_app.yaml (#81317)
Remove data app entity ids from data_app.yaml
* Adjust data apps permissions code (#81093)
* Remove the data app table permission grants (#81584)
* Add permissions_group.is_data_app_group and hide data app permission groups from UI (#81705)
* Add data app user management and access warnings (#81839)
* Address data app user access review feedback
* additional e2e test cases for bugs in manage users page
* fix failing frontend type check on unit test
* fix migration id collision for data apps
* combine migration changes into one file
* fix data app metric sync after trashing copied metrics
* fix cli runtime dependency packaging for embedding sdk
* avoid loading data app bundle during resource setup
* prevent redundant data app collection permission updates
* allow admins to remove data app members after token expires
* update docs and add e2e test for missing resource collection case
* isolate data app sync using transactions
* do not leave user on an empty page when removing the last member on the page
* reserve sandbox-host slug in data app config
* ignore generated data app action fixtures
* move queries into db.clj
* preserve legacy permissions when adding data app members
* regenerate the kondo modules config
The data-apps module reaches for :model/Permissions and :model/DataPermissions,
neither of which was recorded in :model-imports, so metabase.core.modules-test
fails on the current tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP
* reject data app queries whose metrics read another card
Sync copies a referenced metric but rewrites nothing inside the copy, and
copies nothing that copy in turn reads, so those references still point at
the originals. The app publishes successfully and then fails for every
viewer without access to the originals' collections.
Checking while resolving the query, rather than at codegen, also catches a
metric edited into that shape after its schema was generated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP
* exclude metrics that read a saved card anywhere from generated schemas
source-card-id only reads stage 0, so a table-sourced metric that joins a
saved question passed the codegen filter. The CLI checks the whole query and
rejects such a metric, aborting sync-resources for any app that uses it.
Checking the whole query here keeps codegen consistent with what sync
supports, and removes the database round trip that asked which of the
referenced cards were metrics -- the step that let a saved-question join
through in the first place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP
* resolve copied resources' table dependencies through the server
Numeric source-table entries miss implicit joins: a copied model querying
Orders and reading Products.Category through a foreign key names Products
nowhere, so it was left out of the app's permission warnings.
Run the same metadata-based lookup the query endpoint uses, through a new
endpoint the CLI calls for copied models, actions and metrics, and drop the
JSON walk it replaces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP
* cover the create-queries grant the database defaults leave on app groups
An app group that predates the first non-audit database is given
create-queries :query-builder-and-native by set-new-database-permissions!,
alongside the :blocked view-data it special-cases for app groups. A user with
data access through another group would gain native query authoring just by
joining the app.
The reconciler already resets it, but nothing failed when :perms/create-queries
was dropped from its list. The pairing cannot be produced through
set-database-permission!, which cascades view-data to :unrestricted for any
create-queries value other than :no, so the test writes the row the way the
defaults insert it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP
* prevent models based on saved questions from being copied
* return to a valid member page after overlapping removals
* do not resolve query objects when either enabled flag is false
* prevent shorthand properties and spreads from overriding generated ids
* prevent adding duplicate members and retry only members that are failed to add
* define schema for data app query endpoint
* reconcile only the current data app group during resource setup
* expand the schema fingerprint
* add data app group field in permission group schemas
* prevent new databases from granting query creation to data app groups
* throw validation errors when invalid query spec fields are passed
* do not fail validation on columns metadata
* add enabled metadata to query definition schema
* accept more data app specific query metadata and binning options
---------
Co-authored-by: Timofey Kachalov <timofei@metabase.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test(ui): add PasswordInput Storybook story
* test(ui): add PasswordInput to Loki as baseline for restyle
* feat(ui): restyle PasswordInput from Figma and add input-background token
* test: cover PasswordInput visibility toggle in unit and e2e tests
* fix(PasswordInput): use highlight border color in focused error mode by overriding Input styles
* Update Loki Snapshots
* test(ui): add Loki reference for PasswordInput Overview story
* fix(PasswordInput): address review feedback
- keep border width constant and use an inset ring for focus/error to avoid layout shift
- tie right padding to the toggle button size so text clears it in lg
- restrict size prop to sm | md | lg
- order color keys alphabetically
- explicit visibility assertion in the e2e spec
* Update Loki Snapshots
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
* Use JetBrains Mono as the code font
Ship JetBrains Mono (regular + bold) and point the monospace font family
at it, replacing the hardcoded Monaco/Menlo stacks scattered across the
codebase.
- Add the woff2 files, @font-face declarations, and the type, fallback
map, docs and backend test entries that keep the admin font picker in
sync with the fonts directory.
- Route every monospace call site through
--mb-default-monospace-font-family, and define that variable in the
embedding SDK's variable set, where it was previously missing. With
the variable always defined, the per-call-site `, monospace` fallback
is redundant and is dropped.
- Inline the variable at the CodeMirror theme, which was the only
consumer of monospaceFontFamily, and delete typography.ts.
- Set the Code component to 13px/22px per the design spec.
* Update Loki Snapshots
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
Bedrock takes an AWS access key pair and Google Gemini Enterprise a service
account key file, so the AI settings page can't keep calling every provider
connection an API key.
The tests pass ids like 789 for entities they do not create. format-entity looks the
id up, and the test asserts on the fallback rendering that a 404 produces. When a
parallel test happens to hold that id, read-check gives a 403 instead, which renders
nothing, and the test fails.
Use Integer/MAX_VALUE, which no fixture allocates.
Claude-Session: https://claude.ai/code/session_01GCoSa7L7KrXh42rDRYR3Nk
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Read a card's UI parameters off its question
* Drop a tombstone comment
* Pin the agreement between getCardUiParameters and Question.parameters
* Type the unsaved card in the agreement test
* Format the agreement test
* Move the application permissions selector slot into current-user
PLUGIN_APPLICATION_PERMISSIONS.selectors was only read by current-user, so the slot now lives there as PLUGIN_APPLICATION_PERMISSIONS_SELECTORS with its OSS defaults, and the EE application permissions plugin registers into it from metabase/current-user. getRoutes and tabs stay in metabase/plugins for the admin permissions editor.
* Translate viz nouns and formatting operators on demand
`noun` was the one field on a visualization definition still translated at
import. `get noun()` matches what ObjectDetail and TreemapChart already do,
and the registry stores a definition by reference, so the getter survives.
The table-formatting operator names were four module-level objects. They are
functions now. `ALL_OPERATOR_NAMES` becomes `getAllOperatorNames()`, which
its three readers call.
25 of the 124 sites that translate before a catalogue can be applied.
* Translate settings titles on demand
`title` in a settings definition follows the same rule as the rest of the
object. 152 settings already use `get title()`; these 23 were the laggards.
The group-managers confirmation held two frozen strings, so it becomes a
function rather than an object with getters.
49 of 124.
* Lift translated containers into functions
Where a whole array or object is translations, the function goes on the
container so consumers keep a plain array or object: PieChart's ring titles,
the whitelabel illustration options and descriptions, PivotTable's partitions
(which held JSX built at import), the model-caching options, the cloud
migration messages, and the geo filter placeholders.
Where only a field or two of a mixed config is translated, and call sites read
it by property, the getter goes on the field instead. That covers the
collection configs, the permission option records and the loading messages,
none of which are spread anywhere.
Four files carried a whole-file disable rather than one per site, hiding 20
sites behind 4 directives. The real total is 140, not 124.
118 of 140.
* Clear the last module-scope translations
Validation schemas become functions, matching getOidcFormSchema and the other
lazy schemas already here. Module-local error strings become functions called
at their use sites. Three exported constants follow: NULL_DISPLAY_VALUE,
BEFORE_UNLOAD_UNSAVED_MESSAGE and NO_X_AXIS_VALUES_ERROR_MESSAGE, across 37
files.
The two Mantine `aria-label` defaults get a getter. Mantine merges
`defaultProps` by spread on each render, so the getter is read every time.
`ttag/no-module-declaration` now passes across all five trees it covers with
no opt-outs left, and `--report-unused-disable-directives` confirms none were
stranded.
* Stop spreads from flattening lazy translations
A spread or a rest destructure reads every getter it copies. Where an object
holds `get title()` so the string translates when it is read, spreading that
object at module scope translates it at import instead, which is the bug the
getters were meant to avoid.
Adds `mergeLazily` / `omitLazily`, which copy property descriptors instead,
and uses them where the flattening happened at import:
- `viz-core/lib/settings/utils.ts` flattened twice, once in the rest
destructure and once in `...def`, so every setting built through
`fieldSetting` / `metricSetting` / `dimensionSetting` froze at import.
- `palette/shortcuts/index.ts` spread five shortcut maps into one.
- `PaletteShortcutsModal` spread each shortcut again while grouping, so that
now happens per render.
Measured with a test that imports every source module with `ttag` mocked:
40 modules and 370 translation calls at import, down to 35 and 256.
* Build parameter, admin and graph settings when read
* Build option lists when they are rendered
* Guard import-time translation with a test
* Make the instance analytics message a function so the plugin registry can replace it
* Resolve admin path names when the navbar renders them
* Give the enterprise caching tabs a lazy name too
* Skip generated cli-snippets from the side-effect scan
* Key the null slice by its untranslated label
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
* Fail the import-time guard on unlisted import errors
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
* Read lazy translations once per render, not per lookup
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
* Let jest transform the MCP ext-apps package
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
* Skip the SDK cli scripts in the import-time guard
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Namespace the table name BigQuery's compiled native form carries as :qp/table-name
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Update the BigQuery compile tests for :qp/table-name
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Update the remaining BigQuery compile tests for :qp/table-name
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Refuse to create new tables in static datasets with Snowflake.
Table creation will always succeed on non-static datasets, but if it's
static then the table being created has to be a table that's in the
dataset definition.
Ensure all tests which alter the dataset by creating new tables
declare a non-static dataset. The transform-related tests switch over
to using transforms-dataset/transforms-test which most of them are
already using.
Upload tests have their dataset defined in a fixture so we can do it
all in one place instead of editing 60 different deftests.
Update E2E test timings with averaged data
Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).
🤖 Generated automatically by update-e2e-timings workflow
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
* Update map tile server settings with suggested copy from DOC-301
And also include a link to the relevant page in the metabase docs.
* Add a note about not including private keys to the docstring for map-tile-server-url
so that the warning also shows up in the env var docs.
* BigQuery: use PERCENTILE_CONT for median and percentile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* update docs
* update doc
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Fixes UXW-4714.
The static viz for tables uses a fixed width, but it was too big for the
PDFs, causing tables with long text in columns to lay out badly.
This PR disables the fixed width of `780px` on tables for PDFs, so that
the tables now lay out correctly.
* Fix collisions on metabase.notification.payload.core-test namespace name. Lint for future collisions.
* Include .clj-kondo/{src,test} in backend check triggers
* Expect the new project test namespace
* Symbolically reference project test suite namespaces
* Include resources, test_resources, test_config dirs in project checks
* Return [namespace relative-path] pairs.
Skip local/src
* Catch read-file errors and explain why some files may not have ns forms
* Sort paths that collide
* Copy the transform tables in dump-to-h2 and load-from-h2
None of the nine transform_* models were in copy/entities, but metabase_table is,
and it carries a transform_id FK to transform. A table row reached the target
referencing a transform row that never arrived, and the deferred FK check
rejected it at commit.
transform_run_cancelation also goes in entities-without-autoinc-ids, since its
primary key is run_id rather than an autoincrement id.
Adds copied-tables-have-copied-fk-parents-test, which migrates a throwaway H2
database and fails if any copied table's FK parent is not copied.
* Simplify the copy foreign-key regression test
* Clarify the foreign-key coverage test names
* Make defendpoint reject request schemas that reach an open map, a keyword-keyed map-of or :any
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Close the MBQL 5 query and column schemas and declare the keys the query processor adds
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Type the shared schemas requests reach and drop ms/Map and ms/JSONString
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Close every map schema a request can reach
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Regenerate the modules config
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep action parameters string-keyed and type lens params and permission graph ids
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Declare the sandbox coercion keys a column's options can carry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Declare the alias-info and Mongo keys a column can carry and strip internal keys from legacy column metadata
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Carry the Mongo column keys in the serialize test fixture
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Declare the keys the frontend and query processor put on columns, parameters and card embeds
- column metadata: :remapping, a typed :lib/options, the nest-breakouts option key
- query parameters accept the rest of a stored filter declaration
(sectionId, isMultiSelect, values source config etc.)
- static-list values may be one-tuples; raw-value template tags may
carry a nil widget-type
- card embed attrs declare chart_href, child_target_id and host_data;
explore filter refs accept the legacy field_ref the chart column has
- notification card echoes a metric's query_description
- serialization deep-sort orders mixed keyword/string keys
- viz-settings column refs are normalized before matching; a Field
hydrated with its table can be fingerprinted; broken segment
definitions read back as nil
- test fixtures: real fingerprints, snippet tag type, http channel
details, comment content, no min_duration_ms on duration strategies
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Wire the defendpoint check, keep every map single-keyed, and apply the review findings
- defendpoint now calls closed-schemas/check! on every request schema;
the walker no longer lets :any leak through collections, treats
map?/:some as :any, skips :=/:enum/:fn values and never skips the
default entry
- no map mixes keyword and string keys: ms/string-keyed-object gives an
opaque JSON object string-keyed declared entries; ProseMirror attrs
(documents, comments, explorations embeds), CSV upload parts, search
weights and testing query specs use it and their readers use string
keys; the serdes deep-sort comparator is back to master
- the Metabot context is fully typed from the frontend types instead of
an opaque default; a draft transform query without a database is
accepted
- permission graphs, settings PUT and SCIM PATCH reject a body whose
map-of entries the decoder dropped; ad-hoc query endpoints drop a
client-sent cache strategy; action parameter values keep collections
out without nil-ing them; SCIM meta.resourceType is optional; sandbox
user attributes are LoginAttributes; a metric filter clause carries its
offset options; explore filter refs are the legacy field_ref the
chart column has; result_metadata written by older releases and
hasVariableTemplateTagTarget on parameters are declared; a bare
map-of on a request path is guarded against scalars
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Close the endpoints tests define and let a column's target and name field be an id reference
The check runs on every defendpoint, the ones test namespaces define included. A column's
:target and :name-field are hydrated Fields on the backend but id references in the
metadata the frontend hands over; the closed column schema nil'd the reference, which
lost the search field of a model's primary key.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Hand an action's query only the parameter values, and fix two fixtures the closed schemas caught
A query action's stored parameters carry the frontend's widget settings; the query gets
the value keys. The Metabot viewing-context tests use ids no card can have, and the
Mongo cache test spells the strategy key the schema does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Declare the table name BigQuery's compiled native form carries on a native stage
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep a BigQuery RECORD's sub-field sync metadata off its result column, and fix a binning fixture
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Move Snyk scans into the Lint Security workflow
Fold snyk.yml's three jobs into lint.security.yml as snyk-generate-poms,
snyk-frontend-projects, and snyk-maven-projects, and delete snyk.yml.
Snyk now runs on the existing daily 11:00 UTC cron instead of its own
05:00 cron and dependency-file push trigger. The notify job reports Snyk
failures to Slack alongside CodeQL and Trivy, and Snyk's upload-sarif is
bumped from v3 to v4 to match the rest of the workflow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* add timeouts
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* GDGT-3132 fix misaligned undo toast
* migrate UndoListing to from styled components to CSS module
* absolute px -> rem, compact -> compact-md
* fix inconsistent toast height, use line-height instead of measuring the DOM to handle single vs multi line layouts
* strip comments
* GDGT-3195: Fix inefficiencies in formatValue
formatValue is fine per-cell but pathological in bulk; the "Copy results"
button made this visible, with a 100x2000 grid spending ~4.5s in this path.
- Drop a dead dayjs strict parse. The format literal 'T' is not valid dayjs
escaping (dayjs wants [T]), so isValid() returned false for every value.
It sat above the string/number/boolean branches, so every non-date cell
paid for it.
- Only JSON.parse the value when a markdown_template will consume it,
instead of on every call.
- Memoize the per-column type predicates (isURL/isEmail/isTime/isDate/
isNumber/isCoordinate/isBoolean) in a WeakMap keyed on the column. Each
fans out into recursive type-hierarchy lookups that cross into CLJS, and
they only depend on the column. Entries snapshot the type fields they
were computed from, since the custom-viz API hands third-party code
mutable column objects.
- Skip new URL() construction for values with no ":" in getUrlProtocol.
- Cache Intl.NumberFormat by its constructor options, bounded at 100
entries with oldest-first eviction.
Also make the value-formatting signatures honest: formatUrl, getLinkText,
getLinkUrl and getRemappedValue take unknown, which is what they actually
receive. getUrlProtocol keeps its string signature, with getLinkUrl
narrowing before the call. This removes both `value as string` casts.
Benchmarked with a synthetic 100x2000 copy path (medians of 5):
full table 4512ms -> 440ms, text 1231 -> 12, currency 637 -> 20,
integer 535 -> 15, boolean 343 -> 5, email 101 -> 4.
Claude-Session: https://claude.ai/code/session_01KUcocQj2bJPwVBi4dXKyEP
* Cleanup
* Revert the createPlainCellFormatter change
Hoisting the options spread out of the per-cell closure is not worth the
churn. Measured in isolation with a realistic 22-key columnSettings, the
spread costs 0.023us per cell against 0.002us to reuse a hoisted
reference, so it saves ~4ms per 200k cells — a rounding error next to the
dead dayjs parse at ~13.5us per cell, and nothing at all for columns with
click behaviour, where both shapes spread per cell anyway.
* Address review feedback
Guard the markdown_template JSON.parse with a typeof check instead of
carrying the placeholder cast comment.
Cover the two changes the review called out as untested:
- numberFormatterForOptions returns the same formatter for equivalent
options, a separate one for different options, and evicts the oldest
entry once the cache is full. The eviction test resets the module so
the 100-entry boundary does not depend on what earlier tests cached.
- formatUrl passes strings and numbers through untouched and renders
booleans and objects rather than returning them raw.
Both were checked by mutation: breaking the coercion and stubbing the
cache lookup to always miss fails exactly those assertions.
* Move the DataReference library pane slot into querying
DataReference is the slot's only reader and querying owns its props type, so the slot lives next to it as PLUGIN_DATA_REFERENCE.LibraryPane.
The enterprise library plugin registers into it at boot, and plugins/oss/library.ts no longer imports querying's DataReference types.
* Move the verified search filter slot into search
SearchSidebar is the only reader of PLUGIN_CONTENT_VERIFICATION.VerifiedFilter, so the field becomes PLUGIN_SEARCH_FILTERS in metabase/search/plugin, next to the other search filter components. The EE content verification plugin registers the filter there from initializePlugin, and the rest of PLUGIN_CONTENT_VERIFICATION stays in plugins without the search import.
* Declare the search filters slot with definePluginSlot
* Import definePluginSlot from the plugins entry point
* Describe the verified filter default without naming its consumer
* Cover enterprise feature gates and plugin reset behaviour
* Remove redundant plugin registration tests
* Move the useGetIcon plugin slot into hooks/use-icon
The hook is the slot's only reader, so the slot and its OSS default live next to it as PLUGIN_ICON.
The enterprise collections plugin registers into it at boot, and plugins/oss/collections.ts no longer imports metabase/hooks/use-icon.
* Declare the entity icon slot with definePluginSlot
* Import definePluginSlot from the plugins entry point
* Cover enterprise feature gates and plugin reset behaviour
* Remove redundant plugin registration tests
* Trigger the stats workflows from Run tests, gated on the uberjar artifact
Both workflows have recorded nothing since 2026-09-03. PR #81238 took the push
trigger off `uberjar.yml`, so master merges reach it only through
`workflow_call`. A reusable workflow reached that way raises no `workflow_run`
event, and both watchers went quiet. 143 master commits went unmeasured.
They now watch "Run tests", which is what builds the jar on a master merge.
Its conclusion is not a usable gate, because it is `failure` on master more
often than `success`, from jobs that have nothing to do with the jar. The
uberjar artifact is the right gate: a new job looks it up without downloading
it, and a docs-only merge that skipped the build finds nothing and stops.
Calling both from `run-tests.yml` would have been lower latency, but a stats
failure would then turn the master run red, and `rerun-workflows.yml` watches
that run and would re-run master CI over it. Keeping them in their own run
keeps a stats problem to itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk
* Let a PR run the size benchmark against its own uberjar
A `workflow_run` workflow always runs the copy on the default branch, so a
change here cannot be tried out before it merges. This matches the trigger
Bundle Load Stats already carries. A called run measures and reports; the
upload step stays gated on the `workflow_run` path, so it writes no row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk
* Trim the trigger comments in the stats workflows
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Give the visualizations a question builder instead of the metadata object
* Let the visualizations read the question builder themselves
* Read the store when a chart brush builds its question
* Drop the single-use question helper
* Give the map and object-detail views the question builder too
* Drop the last v1 metadata reads from the visualization tree
* Send the Field columns a parameter widget reads in param_fields
* Stop re-resolving a parameter widget's fields through the store
* Keep the empty default a parameter widget's fields prop had
* Read the driver-dependent Field columns in param_fields expectations
* Add a harness for measuring bundle load time
* Report the second load and make the benchmark safe to run in CI
* Run the load benchmark across network and CPU conditions
* Sign the load benchmark in to a real instance
* Track page load times on every master merge
* Backfill load times for recent master commits
* Rewrite the load-time backfill in TypeScript with a tested core
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Launch the benchmark jar the way we ship it, and lint the .ts scripts
* Measure the whole load, not just DOMContentLoaded
* Do not let the performance marks gate a reading
* Drop the backfill workflow
The goal is a global trend, which the per-merge job builds on its own after a
few days. Backfilling only bought a readable chart sooner, at the cost of a
second workflow and the largest script in the PR.
The commit date stamp goes with it. It existed so a backfilled row could claim
its commit's date rather than today's, and the per-merge job never set it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Let a PR run the load benchmark against its own uberjar
A `workflow_run` workflow always runs the copy on the default branch, so a
change here cannot be tried out before it merges. The `workflow_call` trigger
lets CI hand the workflow a commit and reuse the uberjar it already built. A
called run measures and reports; it does not write rows to Stats.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk
* Report each state from one load, not a median per reading
Every reading was medianed on its own, so a row described a load that never
happened. It could also put the readings out of order and print a page-ready
that precedes the TTFB beside it.
`measure.js` now picks the run whose DOMContentLoaded is the median of its
series and reports that run whole. The second visit happens once per browser
profile, so it stays the run itself, and its page-ready now comes from that same
load rather than from a median that included the series' first, uncached run.
`Cold spread %` still comes from all the cold runs. A spread is a property of the
series rather than of one load.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk
* Read the largest paint with an observer, and cut the timeout
The first CI run reported a zero largest paint in all four conditions, and would
have done so forever. These entries never reach the performance timeline, so
`getEntriesByType` cannot see them. Only a `PerformanceObserver` can, and it has
to exist before the paint. Paint entries are buffered, which is why
first-contentful-paint needed none of this.
The browser keeps revising the value until the user interacts, and the reading is
taken once the page reports ready. It is a lower bound rather than the final
figure, which is what makes it comparable between commits.
That same run took 4.4 minutes, of which the measurement was 164 seconds. The
timeout drops from 25 minutes to 10.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk
* Move the bench to frontend/test and write it in TypeScript
---------
Co-authored-by: Fraser <frasermcintosh1@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
For tests of the config itself, use `./bin/mage project-tests modules`. After editing Clojure files, run `clj-paren-repair`. After editing `config.edn`, re-run the modules-test.
For tests of the config itself, use `./bin/test-agent :only '[metabase.core.modules-test]'`. After editing Clojure files, run `clj-paren-repair`. After editing `config.edn`, re-run the modules-test.
**Update your agent memory** as you discover idioms: which modules use `:friends` and why, which models are most-imported (suggesting they belong in their own module), which settings are accessed cross-module via `(setting/get …)` instead of `:uses` deps, and which refactors the team is actively running.
For a single require entry, hang it as metadata on that entry:
@@ -321,12 +320,7 @@ For a single require entry, hang it as metadata on that entry:
[metabase.query-processor.store:asqp.store])
```
Use the map form, not the keyword form (`#_:clj-kondo/ignore`), so searches can identify the suppressed
rules. Keep `:clj-kondo/ignore` as the map's first key; the ratchet scanner rejects other orderings.
Fix the warning when possible. Adding a suppression is a last resort and requires approval. If one is
necessary, add an explanatory `;;` comment directly above it or at the end of the same line. CI enforces
these requirements with `./bin/mage kondo-ratchets`.
Common Metabase-specific rules you'll see: `:metabase/modules`, `:metabase/validate-defendpoint-route-uses-kebab-case`, `:metabase/validate-defendpoint-has-response-schema`, `:metabase/prefer-with-dynamic-fn-redefs`, `:deprecated-namespace`, `:discouraged-namespace`. Don't use the keyword form (`#_:clj-kondo/ignore`); always the map form so it's grep-able by which rule(s) are suppressed.
@@ -31,11 +31,6 @@ Phases 1, 2, 5 and 6 are shared. The middle two differ in **order**: update mode
- **The component's intended API** — variants, sizes, and states the design system supports for this component. This defines the axes of your showcase (Phase 3).
3. Decide the mode (see **Two modes** above): search for an existing component — in `metabase/ui` or as a legacy component elsewhere. Found → **update**; nothing equivalent anywhere → **create**. Confirm with the user when unsure.
4.**Verify a Figma desktop MCP is connected before doing the work** — this skill depends on it for exact tokens in Phase 4 (and styling can't proceed accurately without it). Do a quick read against the issue's node (e.g. `get_metadata`/`get_variable_defs`); if it errors with "nothing selected" or the server isn't connected, prompt the user to enable it (Switch to Dev Mode → MCP section in the right sidebar) and select the relevant layer before continuing.
5.**Figma Desktop MCP environments only:** open the issue's exact page with [`figma-open`](./figma-open) before MCP reads. Do not run this helper when Desktop MCP is unavailable.
A URL with `node-id` needs no token. Bare page-name and `--list` lookups require an already-configured `FIGMA_TOKEN`.
## Phase 2 — Understand the component
@@ -210,7 +205,7 @@ Per `docs/developers-guide/frontend.md` ("Colors"): every color must be a **sema
The same discipline applies to **dimensional** values — radius, spacing/padding/gap, elevation/shadow, and typography (font size, line-height, weight). Prefer the codebase's existing scale variable over a literal: `--mantine-radius-*`, `--mantine-spacing-*`, `--mantine-shadow-*`, and the Mantine font tokens.
But Figma's scales don't always line up with the codebase's. When a Figma value **has no matching step** in the corresponding scale — e.g. Figma `radius/md = 16px` while `--mantine-radius-md` is `0.75rem`, or a Figma spacing step that has no Mantine equivalent — **don't silently bake in a literal.** Flag it to the user the same way you flag primitive colors: name the property, the Figma value, and the nearest codebase token, so it can be resolved with design / the scale owners (align the value, or add a scale step). Only fall back to a literal if the user confirms it's an accepted one-off, and leave a short comment in the code explaining why. The codebase scales are defined in `frontend/src/metabase/ui/theme.ts` (`SPACING_SCALE`, `RADIUS_SCALE`, `LIGHT_SHADOWS`/`DARK_SHADOWS`).
But Figma's scales don't always line up with the codebase's. When a Figma value **has no matching step** in the corresponding scale — e.g. Figma `radius/md = 12px` while `--mantine-radius-md` is `8px`, or a Figma `xxxs` spacing step with no Mantine equivalent — **don't silently bake in a literal.** Flag it to the user the same way you flag primitive colors: name the property, the Figma value, and the nearest codebase token, so it can be resolved with design / the scale owners (align the value, or add a scale step). Only fall back to a literal if the user confirms it's an accepted one-off, and leave a short comment in the code explaining why.
### Mantine implementation notes (reference)
@@ -245,6 +240,5 @@ Only after sign-off:
- [ ] Storybook showcase matrix built from the component's full state space (theme excluded — global toggle drives light/dark), single panel, hover/pressed forced via `storybook-addon-pseudo-states`, controls scoped per story; lint + type-check pass. (Update mode: built before styling, **component styles untouched**. Create mode: built after the component is implemented in Phase 4.)
- [ ] Figma desktop MCP available; exact tokens extracted and mapped to semantic `--mb-color-*` (NO `color-mix`, NO primitives) and to existing scale vars for radius/spacing/elevation/type — any color *or* dimensional value with no matching token/scale step flagged to the user, not baked in as a literal.
@@ -17,10 +17,10 @@ Adherence to `typescript-write` is the **highest-priority** review dimension: ra
Review in this priority order:
1.**Violations of [`typescript-write`](../typescript-write/SKILL.md) provisions** — no-`any`, type tightening, type modeling, null/undefined handling, naming, structure, comments. Highest priority; block on the no-`any` rule.
1.**Violations of [`typescript-write`](../typescript-write/SKILL.md) provisions** — no-`any`, type tightening, type modeling, function signatures, null/undefined handling, naming, structure, comments. Highest priority; block on the no-`any` rule. Apply conditional guidance in context: explain the unsupported type guarantee or concrete readability problem, rather than treating every preference as a blanket ban.
2. Compliance with `frontend/CLAUDE.md`.
3. Readability and maintainability.
4. Appropriate test coverage.
4. Appropriate test coverage. For internal typed callers, avoid requesting tests solely for inputs the type system excludes. External API data, deserialised values, storage and JavaScript callers can violate annotations: test runtime validation and nontrivial assumptions at those boundaries. Types do not replace behavioural, security or data-integrity tests.
## Blind spots — act as the missing reviewer
@@ -31,3 +31,4 @@ These rarely surface in team reviews, so this skill should raise them. They are
- **Security.** Evaluate potential security issues in new code.
- **Bundle size.** Flag new large dependencies, default imports from icon or util libs, and heavy modules imported at route-load time.
- **Analytics.** User-facing flows should emit tracking events. If a PR adds a new flow (button, modal, navigation) without a tracking event, ask whether one is expected.
- **Public API surface** (embedding SDK). Consumers should be able to use public signatures and name types they need to import. Export those types deliberately and document public behaviour, including `@deprecated` for deprecated APIs; a referenced structural type does not automatically need its own named export.
@@ -20,20 +20,27 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
- **Avoid type casts and loose `unknown`** — fix the signature instead.
- **If a function only needs one field of a wide object, accept that field** — not the wide object. The cast often disappears once the signature is right.
- **Reach for `Partial<T>`, `Pick<T, K>`, `Record<K, V>`, and generics** before reaching for a cast.
- **Match dictionary keys to the data.** Use a finite key union when the keys are known. Open dictionaries can use an index signature, `Record<string, T>`, or `Map`; index signatures still constrain values, and changing their spelling to `Record<string, T>` does not make missing-key access safe.
- **Prefer making props/components generic** (`<T>`) when a value flows through unchanged and the caller knows the type.
- **Prefer `unknown` over loose typing** and narrow before use — an `unknown` value forces a guard at the point of use.
- **`satisfies` for object literals** that must conform without widening (config objects, lookup maps, discriminated literals) — better than `: T` (widens) or `as T` (unsafe).
- **Avoid non-null assertions (`!`)**. Prefer a guard, early return, or `?.`. Use `!` only when non-nullness is provably true and localized, with a comment.
- **Guard indexed lookups that may miss.** Arrays and dictionaries can return `undefined` even when the inferred type omits it. Use an iteration form that preserves the key/value relationship, and only assert `keyof` when the runtime keys are known to belong to the declared type.
- **Type guards belong in `frontend/src/metabase-types/guards/`**. Do not redefine them locally.
- **A cast you can't avoid needs a real justification comment.** The `metabase/no-unjustified-type-casts` rule accepts any preceding comment — state the actual reason the cast is safe. NEVER write the legacy `// Unjustified type cast. FIXME` placeholder; it exists only on casts that predated the rule, and copying it sneaks an unjustified cast past the linter. If you can't articulate why the cast is correct, the cast is wrong — fix the types.
- **Keep unavoidable assertions local.** Isolate a repeated or complex assertion behind a helper when that makes its invariant easier to enforce. Test nontrivial runtime assumptions that justify it; a trivial assertion does not automatically need a new helper or test. Do not weaken a public signature just to silence implementation errors.
## Type modeling
- **Reuse existing types; don't re-declare them.** Use canonical IDs and domain entity types from `metabase-types/api` (`FieldId`, `TableId`, `ConcreteTableId`, `SchemaName`, …) and key data structures by them (`new Map<ConcreteTableId, …>()`). Don't duplicate generated/API types — compose or derive (`Pick`, `Omit`, indexed access `SomeType["field"]`, `ReturnType`).
- **Use generics to allow TypeScript to infer correct types** when creating functions and components that need to be reusable and type-safe. Don't hesitate to introduce complex generics if they allow to derive types automatically instead of manual narrowing.
- **Generics must make promises the implementation can keep.** A caller-selected `get<T>(): T` must not disguise an unchecked assertion about external data. Return `unknown` and validate, or accept a validator that establishes `T`. A factory such as `function empty<T>(): T[] { return []; }` is valid; judge the implementation, not how often `T` appears in the signature.
- **Prefer an honest type over false precision.** Use generics when they express a real relationship. If a complex type cannot model the behaviour accurately, choose a simpler type or `unknown` with narrowing instead of asserting an unsupported guarantee.
- **Model the actual data contract; keep types narrow.** Optional `field?: T` for a key that may be absent, `field: T | undefined` only when the key is always present but the value may be undefined, `| null` for explicit API nulls. Prefer domain unions over broad `string` / `number` / loose `Record`.
- **Refer to API implementation** when defining or refining types to ensure they match the actual data structure. When considering a type cast, first consider if the type should be refined to match the actual data structure.
- **Optionality must reflect absence.** Keep required fields required and optional fields optional. Normalise input when the application has a meaningful default, not merely to remove a type error. Preserve the actual wire shape in API types.
- **Represent related absence together when modelling internal state.** If several fields exist or disappear together, consider a nullable containing object or a discriminated union. Do not reshape a raw API declaration unless the data actually has that shape.
- **Prefer explicit special states when designing a format.** Use nullability or named union variants when sentinel values such as `-1` hide meaning. Preserve established protocol sentinel values unless the behaviour is deliberately changed, or normalise them at an explicit boundary.
- **Discriminated unions for variant state, with exhaustive checks.** Model "one of N shapes" as a union with a literal discriminant rather than a bag of optional fields, and exhaust it with ts-pattern's `.exhaustive()` so adding a variant becomes a compile error:
```ts
import { match } from "ts-pattern";
@@ -45,20 +52,30 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
.exhaustive(); // Compile-time guarantee all cases handled
```
- **Derive union types from constants** (`as const` + `typeof`/`keyof`) so the type and the values can't drift.
- **`readonly` / immutability where mutation isn't intended** — component props, shared constants, exported config. Prefer `readonly T[]` / `ReadonlyArray<T>` for inputs you don't mutate.
- **`readonly` / immutability where mutation isn't intended** — component props, shared constants, exported config, and unmutated parameters. Prefer `readonly T[]` / `ReadonlyArray<T>` for inputs you don't mutate. Functions that mutate caller-owned data should make that behaviour explicit.
- **Construct complete, well-typed objects where practical.** Prefer an object expression when it avoids partially initialised state or assertions. Incremental construction is fine when it is clearer and maintains the type's invariants.
- **Treat `metabase-lib` opaque types as black boxes.** Use `metabase-lib` functions to work with types such as `Lib.Query`, rather than casting into their internal representation.
- **Type async and error states explicitly** (a discriminated union or the data-layer's typed result) — never leave loading/error/empty implicit.
## Function signatures
- **Make public contracts explicit where it improves stability and clarity.** Annotate parameters and return types at shared boundaries when useful; let local values infer. Use `satisfies` or an annotation when a declaration needs an explicit shape check.
- **Accept the inputs the operation supports and return the most precise honest result.** Narrow avoidable uncertainty inside the function, but preserve meaningful nullability and union variants in its return type.
- **Use named options when positional arguments are easy to confuse.** Consecutive arguments with the same type are a useful warning sign, not an automatic requirement to rewrite a clear API.
- **Use `async`/`await` when it clarifies control flow or error handling.** Returning an existing promise directly is also valid; a Promise return type alone does not require adding `async`.
## Null and undefined
- **Narrow at the source**. If a value is optional only in a corner case, don't thread `undefined` through every layer — guard at the producer.
- **Sensible defaults for optional values**. Use `?.` and `??` at the consumer.
- **Lists should be filtered** before being used in a map or other iteration.
- **Narrow nullable list elements when the operation requires present values.** Filter with a type guard when missing entries should be discarded; preserve them when their absence or position carries meaning.
- **Avoid non-strict null comparisons** (`X != null`) when `X` can never be `null` — use a strict check or narrow the type. Use `checkNotNull` where necessary.
- **Check actual nullability against API implementation**. Find the API endpoint implementation and check if the field can actually be null.
## Naming
- **Names describe the entity, not the mechanism**. A name must reflect what the value holds.
- **Use domain vocabulary and include units where useful** (`timeoutMs`, `widthPx`, `temperatureC`). Prefer a more specific name when `Info`, `Data`, or `Entity` obscures the meaning, while keeping established terminology when it is clear.
- **Align sibling concepts**: keep verb conventions consistent across a related API.
- **No names that encode implementation history** rather than current meaning. Suffixes like `Base`, `New`, `Old`, `Initial` need a real semantic distinction, otherwise drop them.
- **Avoid cryptic identifiers** (`v`, `n`, `$n`) for domain values; short names are fine only in tiny conventional contexts (loop index `i`, coordinates `x`/`y`, generic params `T`/`K`/`V`).
{metabase-enterprise.entity-retrieval.core/search-unfiltered{:message"Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
metabase.entity-retrieval.core/search-unfiltered{:message"Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
metabase.entity-retrieval.mirror/search-unfiltered{:message"Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
metabase-enterprise.transform-testing.expectations.equals/->EqualsRows{:message"Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.equals/map->EqualsRows{:message"Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.equals/->EqualsSql{:message"Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.equals/map->EqualsSql{:message"Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.empty/->Empty{:message"Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.empty/map->Empty{:message"Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
clojure.core/print{:message"Use metabase.util.log instead of clojure.core/print"}
clojure.core/println{:message"Use metabase.util.log instead of clojure.core/println"}
clojure.core/printf{:message"Use metabase.util.log instead of clojure.core/printf"}
) 2>&1|whileIFS=read -r line ||[ -n "$line"];doprintf'[p%s] %s\n'"$idx""$line";done
rc="${PIPESTATUS[0]}"
echo"$rc" > "$STATUS_DIR/p$idx"
}
rm -rf "$JVM_DIRS_ROOT"
mkdir -p "$STATUS_DIR"
echo"Runner $TEST_RUNNER_INDEX of $TEST_RUNNER_COUNT: running $TEST_JVMS_PER_RUNNER concurrent JVMs for partition(s) ${INDEXES[*]} of $PARTITION_TOTAL (-Xmx$TEST_HEAP_PER_JVM each)"
# Run one app-DB job on demand, against the ref you dispatch on, optionally under a profiler.
# Everything else about the job -- services, matrix legs, test args, quarantine gating -- is
# whatever the normal CI run uses, because it is the same job.
workflow_dispatch:
inputs:
app-db:
description:'Which app-DB job to run. Only this one runs; the others are skipped.'
type:choice
required:true
options:
- mariadb
- mysql
- postgres
tests:
description: 'Optional:run only these tests, comma- or space-separated, e.g. metabase.db.custom-migrations-test/some-test. Bare namespaces work too. Blank runs the job''s usual set.'
type:string
required:false
default:''
profile:
description:'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
# Run the backend test suite on demand, against the ref you dispatch on, optionally under a
# profiler. Everything else about the jobs -- matrix legs, tag filters, quarantine gating -- is
# whatever the normal CI run uses, because they are the same jobs.
workflow_dispatch:
inputs:
tests:
description: 'Optional:run only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank runs each leg''s usual set.'
type:string
required:false
default:''
profile:
description:'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
description:Run the full suite regardless of the diff (master / release branches, ci:run-all label).
type:boolean
default:false
# Run one driver job on demand, against the ref you dispatch on, optionally under a profiler.
# Everything else about the job -- credentials, services, test args, quarantine gating -- is
# whatever the normal CI run uses, because it is the same job.
workflow_dispatch:
inputs:
driver:
description:'Which driver job to run. Only this one runs; the usual decision rules are bypassed.'
type:choice
required:true
options:
- athena
- bigquery
- clickhouse
- databricks
- druid-jdbc
- h2
- mongo
- mongo-sharded-cluster
- mongo-ssl
- mysql-mariadb
- oracle
- postgres
- presto-jdbc
- redshift
- snowflake
- sparksql
- sqlite
- sqlserver
- vertica
tests:
description: 'Optional:run only these tests, comma- or space-separated, e.g. metabase.driver.bigquery-cloud-sdk-test/sync-test. Bare namespaces work too. Blank runs the job''s usual set.'
type:string
required:false
default:''
profile:
description:'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
# Run one semantic-search job on demand, against the ref you dispatch on, optionally under a
# profiler. Everything else about the job -- services, matrix legs, test args, quarantine gating --
# is whatever the normal CI run uses, because it is the same job.
workflow_dispatch:
inputs:
suite:
description:'Which semantic-search job to run. Only this one runs; the others are skipped.'
type:choice
required:true
options:
- h2
- mariadb
- mysql
- postgres
- appdb-mode
tests:
description: 'Optional:run only these tests, comma- or space-separated, e.g. metabase-enterprise.semantic-search.index-test/some-test. Bare namespaces work too. Blank runs the job''s usual set.'
type:string
required:false
default:''
profile:
description:'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
smoke-only:
type:boolean
required:false
default:false
description:Only run Postgres Latest to do a quick resource-efficient sanity check, intended to be set for PRs.
# The only on-demand entry point for the backend test suites. Dispatching this workflow runs one
# job of one suite against the ref you dispatch on, under clj-async-profiler, and attaches a
# flamegraph to the job.
#
# The suite workflows below are called unchanged: services, matrix legs, test args and quarantine
# gating are whatever the normal CI run uses, because they are the same jobs. They declare nothing
# for this feature -- they read `suite`, `db` and `tests` off `github.event.inputs`, which inside a
# called workflow is this dispatch's payload.
#
# `suite` picks the workflow, `db` picks the leg within it. `db` is free text so that adding a
# driver or an app-DB leg needs no edit here; an unknown driver fails the drivers run through
# mage's `--only-driver`, and an unknown app-DB or semantic-search leg matches nothing and runs no
# jobs.
#
# Renaming an input here means updating every reader -- `grep -rn github.event.inputs .github/`.
# Nothing enforces that, deliberately: a stale name reads as empty, which runs every leg of the
# suite unprofiled, on the run you are watching. It cannot affect a normal CI run, where those
# references are empty either way.
on:
workflow_dispatch:
inputs:
suite:
description:'Which suite to profile.'
type:choice
required:true
options:
- backend
- app-db
- drivers
- semantic-search
db:
description: 'Which leg of the suite:app-db takes mariadb, mysql or postgres; drivers takes a driver name, e.g. bigquery or mongo-ssl; semantic-search takes h2, mariadb, mysql, postgres or appdb-mode. Ignored by backend. Blank runs the whole suite -- every leg for app-db and semantic-search, mage''s usual decisions for drivers.'
type:string
required:false
default:''
tests:
description: 'Optional:profile only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank profiles the leg''s usual set.'
type:string
required:false
default:''
# These jobs gate on `inputs.suite` -- this workflow's own input. Only the called workflows reach
# for `github.event.inputs.*`, because `inputs` there is their own `workflow_call` signature.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.