Skip to content

fix(server): make index.malloy the one list of what dashboards, notebooks and the model GET can read - #1236

Merged
jswir merged 16 commits into
mainfrom
jswir/index-surface-fixes
Sep 25, 2026
Merged

jswir merged 16 commits into
mainfrom
jswir/index-surface-fixes

Conversation

@jswir

@jswir jswir commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

A package's root index.malloy is now the one list of what anyone can read, through every route. An agent querying index.malloy, a dashboard tile, a notebook cell and the model GET all see the same sources. Builds are unaffected.

What stops working (read first)

These remove access that works on 0.8.0. All of them are inert with no surface and under queryableSources: "all".

  • GET .../models/{path} answers 404 for a file off the surface, with the query route's message. Files it does return carry only the names they publish: modelDef (contents, exports, sourceRegistry, queryList; references is dropped, and a published source no longer carries the extends / sourceID / referenceID of a hidden one), modelInfo, sources and sourceInfos. A join to a hidden source keeps its name and its fields' names and types, which is what querying through it needs, but not the hidden source's table, SQL or connection. sourceText is left out when the text names a source the file does not publish, backticked and non-ASCII names included. A dashboard's text is always returned, because the editor saves with it.
  • Notebook cells are held to the surface. A cell over a hidden source answers 404, and 404 rather than 403 when the source is also gated. The notebook GET and cell response list only sources the notebook may read, and the notebook GET leaves out queryInfo for a cell that would be refused. A source an earlier cell derives from a published one still works. A cell's own source over a raw table has nothing published under it, so on a curated package it answers 404.
  • A dashboard file admits nothing of its own. Its export {} and its own named queries no longer make a hidden source queryable, even when explores lists the file. A tile that relied on that answers 404 and is named in the load warnings.
  • Dashboards that explores left out on purpose are now listed. To hide one, remove its # artifact tag, and take it out of explores if that lists it: an untagged file explores lists is published like any other file, as on main.
  • Every dashboard file is now a query path for any caller, not only for its tiles. run: secret there answers 404, but a query over a published source can still join a hidden source the dashboard imports: run: orders extend { join_one: s is secret on id = s.id } -> { group_by: s.x } returns rows. That is how index.malloy already behaves, since the check is on the source a query runs. Only tiles are checked at load. Gate such sources with #(authorize).

What changes

  • Every dashboard is listed, whatever the surface. Tiles, a single-query dashboard's query, and filter suggest read only the surface. A source the dashboard declares on top of a published one can be read (source: big is orders extend {...}); one over a hidden source cannot. The load warns once per tile, query or filter that will fail:
    Tile orders_staging -> by_flag on dashboard overview reads orders_staging, which index.malloy doesn't export, so it won't load. Fix: add orders_staging to the export { ... } in index.malloy.
    
    The lint runs the same two checks as the query route, and re-runs after a metadata PATCH. In a package that gates anything with #(authorize), the warning says "a source" rather than naming it, as the query's own 404 does. An error other than a failed compile makes the lint report itself incomplete instead of passing the tile.
  • Warnings are two sentences: what is wrong in this package, then Fix: and the one edit. A root index.malloy with no keys gets none. Every non-empty explores is deprecated, and the fix names the files index.malloy must import. explores: [] is deprecated: to publish everything, rename index.malloy. Index.malloy in any other case is reported as ignored. The held-back and withheld-drill warnings are gone.
  • queryableSources stays as feat(server): a package's index.malloy is its published surface #1206 left it: marked deprecated in the spec, "declared" warns, and "all" loads with no warning. Nothing replaces "all" for hiding an #(authorize)-gated source from listings while authorized callers still query it.
  • The dashboard editor builds its field catalog from the published models, limited to what the dashboard's imports can see. The files it imports are now 404 when they are off the surface. One model that fails to load drops only its own sources from the catalog.
  • Skills, docs, api-doc.yaml, the scaffolder briefing and RELEASE_NOTES.md (an [Unreleased] (BREAKING) section) teach the one rule.

Unchanged on purpose

  • Materialization and pre-aggregation builds walk every model, so a hidden #@ persist intermediate is still built and an exported source reads its table.
  • /compile and compile_model stay exempt.
  • The check is on the source a query runs (see the dashboard bullet above). Curation is discovery; #(authorize) is the lock.

How it was checked

  • 19 small test packages, one per surface shape (no surface, root index.malloy, explores in each form, dashboards, a notebook, a case-variant file name, an index that exports nothing, a dashboard with tiles over hidden and derived sources, and a hidden persist intermediate) on a local build of this branch: 54 of 54 checks pass. Covered: listings, query status per route, model GET contents, exact warning text, notebook cells, and a materialization build.

  • MCP: get_context on an explores-listed dashboard package offers only index.malloy::orders, and execute_query refuses a hidden source through a dashboard path.

  • SDK: 1059 pass. Server unit: 4266 pass. The 35 failures are in config.spec and config.theme.spec, and they fail on unmodified main too (bun 1.4). Dashboards and index-convention integration: 67 pass. The full integration run passed except concurrent_package, which times out downloading from GCS without credentials here.

  • A real-server run found three modelDef keys still naming a hidden source that the unit test missed, because it searched for a quoted name inside JSON-encoded text. Review then found two more: sourceText of a file that runs a hidden source, and extends / join identities inside a published source. All are fixed, and the test fails without the fix.

  • The dashboard join path was confirmed against the real compiler: run: secret on a dashboard path is refused, the join form returns rows, identically on index.malloy.

  • Review follow-ups (the last five commits) were checked on a local build: all 19 test packages again, plus a demo package with dashboards, a notebook, named queries and a gated source, clicked through in the Console. Named queries are covered by a new test: an exported one is listed and runs, even through a join to a hidden source; an unexported one and one in a hidden file are neither listed nor runnable. Each new assertion fails on the commit before it. Server unit: 4268 pass (the same 35 config failures). Integration: 359 of 360 (concurrent_package, as above). SDK, skills, scaffolder, hammer, scripts, examples and the Python skill tests all pass.

Known issues, not fixed here

  • A dashboard tile over a named query that reads a hidden, #(authorize)-gated source answers 403 naming the source, not the surface's 404. The dashboard's named query defers the surface check to after compile, and the early authorize check refuses first (model.ts, the assertAuthorized(earlySource, …) fast path). A direct run: <source> -> … on the same path correctly answers 404. Reachable only through this PR's dashboard path; to be fixed in a follow-up.
  • A bad filter value reads Filter expression parse error: [object Object]. The Malloy compiler builds the message from the parse log entry rather than its message. Publisher forwards given values unchecked, and the SDK draws a free-text box for filter<boolean>. Present on main; separate fixes upstream and in Publisher.
  • The notebook refusal tells a saved cell to "address the query to index.malloy", and names no source. A filter's tag text can still name a hidden source in the model GET, when a published source uses that filter.

Not done

  • The dashboard editor was not opened in a browser; its catalog change is covered by unit and spec tests only.
  • Left for later: returning the surface as data on the package response, a warning for "all", and refusing a surface that exports nothing (such a package still publishes, with a warning).

🤖 Generated with Claude Code

jswir and others added 9 commits September 24, 2026 18:30
…ckage surface

A root index.malloy used to hide every dashboard, and the only way to get
one back was an explores listing index.malloy and each dashboard file.

Now every tagged dashboard is listed and served whatever the surface is.
What a dashboard can read is the surface itself: the names index.malloy
exports (or, on a legacy package, what the files explores lists export).

- A dashboard file is a query entry point but admits nothing of its own:
  its export {} and its own named queries no longer make a hidden source
  queryable, even when explores lists the file.
- A dashboard's own named query (single-query dashboards, suggest
  query=) is checked by the source it reads, after compiling.
- A source the dashboard declares on top of a surface source
  (source: big is orders extend {...}) is admitted; one on top of a
  hidden source is not.
- The load lint reports each tile, single query, or filter suggestion
  that will 404, in one sentence plus the fix, and re-runs after a
  metadata PATCH.
- Dashboard files no longer appear as models or count toward the
  surface in listings and surface warnings.

The held-back and withheld-drill warnings are gone with the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: James Swirhun <james@credibledata.com>
…y what it publishes

GET .../models/{path} returned any model file, off the surface or not,
with its full compiled IR and its text. Hiding a file from the listing
did not stop anyone opening it by URL, and even the surface file's own
response carried every source it imports in modelDef.

- A file off the surface answers 404, with the query route's words.
  Inert with no surface and under queryableSources "all". Dashboard
  files pass. The controller no longer wraps that 404 into a 500.
- modelDef.contents and exports, modelInfo, sources and sourceInfos are
  limited to the names the file publishes. For a dashboard that is the
  names that trace to the surface. imports and every other key stay.
- sourceText is withheld when the file declares something it does not
  publish. A dashboard's text is always returned: the editor needs it to
  save.
- The dashboard editor builds its field catalog from the published
  models, limited to what the dashboard's imports can see, instead of
  fetching the files it imports (now 404 when they are off the surface).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: James Swirhun <james@credibledata.com>
…y what they may read

A notebook's cells ran with no surface check, so GET .../cells/{i}
returned rows from a source index.malloy hides, and the notebook GET
listed every imported source's schema.

- Each code cell runs the query route's two checks before anything
  else, so a hidden source answers 404, and 404 rather than 403 when it
  is also gated. A source an earlier cell derives from a published one
  (source: mine is customers extend {...}) still counts as published.
- The notebook GET and the cell response show only the sources, queries
  and newSources the notebook may read.
- A cell's own source over a raw table has no published base, so on a
  curated package it is refused. Inert with no surface and under "all".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: James Swirhun <james@credibledata.com>
… two sentences

The warnings still sent people to explores for three jobs: opting out,
serving dashboards, and listing several files. Dashboards no longer
need it, and index.malloy answers the other two. The warnings also ran
60 to 150 words and read as documentation.

Each warning is now what is wrong in this package, then "Fix:" and the
one edit, with the package's real file names:

- A root index.malloy with no keys, the recommended shape, gets no
  warning (INDEX_MODEL_IS_THE_SURFACE is gone).
- Every non-empty explores is deprecated. The fix names the files
  index.malloy must import, leaving out index.malloy and dashboard
  entries, which need no replacement.
- explores: [] is deprecated too: beside an index.malloy, the opt-out is
  now renaming the file; without one, the key does nothing.
- queryableSources "declared" does nothing; "all" is kept, with no
  warning, as the one way to hide a gated source from listings while it
  stays queryable.
- A root file that differs from index.malloy only in case (Index.malloy)
  is said to be ignored. The exact-match rule is unchanged.
- The malformed-explores refusal, the surface-widened notice and the
  get_context empty-package hint no longer point at explores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: James Swirhun <james@credibledata.com>
…ebooks read

The skills and the scaffolder still told agents to declare explores to
serve dashboards, and said a several-file explores and
queryableSources "all" were supported without a warning.

- malloy-publish: explores is deprecated in every form; several files
  are one index.malloy importing them. "all" is kept only for hiding an
  #(authorize)-gated source from listings.
- malloy-dashboards: Step 0 is gone. The lint section explains the
  tile-won't-load warning and its fix, and that dropping # artifact is
  how to hide a dashboard.
- malloy-notebooks, malloy-source-unreachable: cells, tiles and the
  model GET answer 404 for a hidden source.
- create-malloy-package: the generated briefing no longer mentions
  explores, and the index.malloy template's "all" note stands alone.
- init_truth_package.py stops writing explores. It does not add an
  index.malloy: goldens are queried at truth.malloy, which would then
  be off the surface.
- skills_bundle.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: James Swirhun <james@credibledata.com>
- dashboards.md, discovery-and-access.md, packages.md, api-doc.yaml:
  dashboards are always listed and read only the surface; the model GET
  answers 404 off the surface and returns only published names;
  notebook cells are held to the surface; the new warnings.
- queryableSources is no longer marked deprecated in the API spec: only
  "declared" is, and "all" stays supported.
- materialization.md: builds ignore the surface, so a hidden persist
  intermediate is still built.
- RELEASE_NOTES: an Unreleased (BREAKING) section, which reverses the
  0.7.0 advice to use explores for dashboards and explores: [] to opt
  out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: James Swirhun <james@credibledata.com>
The previous commit dropped `deprecated: true` from the field. The key
stays deprecated: "declared" warns because it does nothing, and "all"
loads with no warning only because nothing replaces it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: James Swirhun <james@credibledata.com>
The running server showed the surface file's response still named a
hidden source it imports, in three places the earlier commit missed:
sourceRegistry (keyed name@file), queryList (top-level run: statements,
with their source inline), and references (editor go-to-definition
data). The first two are now limited to published names; references is
dropped under curation, since nothing that reads this response uses it.

The test that should have caught this searched for a quoted name, which
never matches inside the JSON-encoded modelDef. It now searches the
text, and fails without this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: James Swirhun <james@credibledata.com>
… rename breaks

From review of this PR.

- sourceText was withheld only when the file declared an unpublished
  source. A surface file that imports a hidden source and runs it
  (run: hidden -> ...) declares nothing and still returned its text. It
  is now withheld when the text names any source the file does not
  publish, read outside comments and string literals.
- A published source built on a hidden one carried extends:
  "hidden@file" in modelDef, and a join carried the joined source's
  sourceID and referenceID even when renamed. Those identities are now
  removed for unpublished sources. The join's own name stays: it is part
  of the published field paths.
- The explores: [] and "index.malloy is ignored" fixes said to rename
  index.malloy without saying that every import of it then fails, which
  takes the whole package down. Both now name the imports.
- The release note says plainly that a dashboard file is a query path
  for any caller, and that a query there over a published source can
  join a hidden source the dashboard imports, as index.malloy already
  allows. Only tiles are checked at load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: James Swirhun <james@credibledata.com>

@Sha-Bang Sha-Bang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Three should-fix items inline, none blocking.

  • packages/server/src/service/model.ts:6800: showsFileText misses hidden names that are backticked with a hyphen or are non-ASCII
  • packages/server/src/service/model.ts:6866: the notebook GET still returns each cell's queryInfo schema unfiltered
  • packages/server/src/service/package.ts:548: an untagged dashboards/*.malloy listed in explores is still an entry point for its own exports

Comment thread packages/server/src/service/model.ts Outdated
Comment thread packages/server/src/service/model.ts Outdated
Comment thread packages/server/src/service/package.ts
…o load

The editor now fetches every published model for its field list, not just
the files a dashboard imports, so one failed fetch (a reload racing it, say)
emptied every suggestion with no error shown. Build the catalog from the
models that loaded.

Signed-off-by: James Swirhun <james@credibledata.com>
dashboard.ts pointed at the deleted Package.isQueryableEntryPoint, and the
dashboards-convention fixture comment contradicted the tests under it.

Signed-off-by: James Swirhun <james@credibledata.com>
…tebook and dashboard reads

- Model GET: a join to a hidden source carried that source's whole compiled
  definition (table path or SQL, connection, every column). It is now cut
  to the join's name and its fields' names and types, which is what a query
  through the join needs. Querying through the join is unchanged; the full
  model is still what queries compile against.
- Model GET sourceText: the check read only ASCII words, so a hidden
  `orders-staging` or `café` never matched and the text went out. It now
  uses the same identifier pattern as buildDerivationBaseMap (moved into a
  shared malloyIdentifiers in query_text.ts).
- Notebook GET: queryInfo is left out for a cell the query route would
  refuse, since its schema lists the hidden source's columns.
- Dashboard lint: a warning named the source even in a model gated with
  #(authorize), where the query's own 404 keeps it back. It now names it
  only for an explained (OffSurfaceError) refusal. A non-compile error in
  surfaceRefusal is rethrown so lintDashboards reports the lint as
  incomplete, rather than counting the tile as fine. The per-tile checks
  run with Promise.all.
- A dashboards/ file with no # artifact tag, listed in explores, was still
  an entry point for its own exports: hidden but queryable. It is no longer
  an entry point.

Tests: each new assertion fails on the previous head and passes here.
Signed-off-by: James Swirhun <james@credibledata.com>
An exported named query is listed and runs, even through a join to a
hidden source, without carrying that source's SQL. One the surface file
declares but does not export, and one in a hidden file, are left out of
the response and refused by name.

Signed-off-by: James Swirhun <james@credibledata.com>
…, as main does

Only a tagged file is a dashboard, so the exclusions from the model list,
the surface, and the load warnings now ask discovery rather than matching
the dashboards/ path. An untagged file explores lists is published like any
other listed file again: listed, queryable, and its exports count. This
replaces the previous commit's choice of refusing it, which changed what
the deprecated explores key means.

Signed-off-by: James Swirhun <james@credibledata.com>
The release notes, the malloy-publish skill, discovery-and-access,
packages.md and the spec now say the same thing: the files explores lists
are listed and queryable, and their exports are the surface, wherever they
live. The one change is a tagged dashboard it lists, which reads the
surface and adds nothing to it. Skills bundle regenerated.

Signed-off-by: James Swirhun <james@credibledata.com>
A reader with no history gets nothing from "as before"; say what the key
does.

Signed-off-by: James Swirhun <james@credibledata.com>
@jswir
jswir enabled auto-merge (squash) September 25, 2026 04:20
@jswir
jswir disabled auto-merge September 25, 2026 04:23
@jswir
jswir merged commit 69ed229 into main Sep 25, 2026
23 checks passed
@jswir
jswir deleted the jswir/index-surface-fixes branch September 25, 2026 04:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants