feat(server): a package's index.malloy is its published surface - #1206
Conversation
…ean, and remove #(partition) `#(authorize)`'s body was any Malloy boolean the compiler accepted, which put shapes on the security boundary that only failed at request time and shapes whose meaning depended on whether a given resolved. It now parses its own grammar at load: one or more terms joined by `and`, each `field_path <op> $GIVEN` or `'literal' <op> $GIVEN`, with the operator fixed by the given's declared arity. A whole body of exactly `false` is the one exception, an unconditional deny, because every ordinary term compares against a caller-suppliable given and a locked base needs a spelling. `#(partition)` is removed rather than carried forward. Its body parser becomes the grammar parser, so the work is reused while the untrusted tag is not. The route is refused at load rather than simply unregistered: Malloy's bracket routing is generic, so a leftover marker stays parseable forever, and a source carrying one would otherwise load clean and serve every row. That refusal walks the whole IR, joins included, since no marker on the route is legitimate anywhere now. `PartitionGraftEntry` is renamed, not deleted; it is the shape the authorize row-level classification produces and both paths fed one graft list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Nathan Huff <nathan@credibledata.com>
`#(authorize) $ROLE = 'admin'` is the same comparison as `'admin' = $ROLE` and was the spelling this repo's own integration fixtures used, so refusing it broke `tests/fixtures/query-givens` and `tests/fixtures/authorize-compile` and with them every HTTP end-to-end suite that loads those packages. Neither side of a source-level term is a column, so the order carries no meaning and the term is normalized to literal-on-the-left before the checks run. A row-level term stays irreversible: its field path is what the build scan groups by and what the graft filters on. Caught by the cross-platform job, which runs `tests/` as well as `src/`. The local runs behind the parent commit covered `src/` only. Signed-off-by: Nathan Huff <nathan@credibledata.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…el route Two slices deferred out of the grammar landing, both in publisher scope. Repeating `#(authorize)` is a conjunction rather than a load error. The deferral rested on two wrong claims: that repeats meant OR, and that landing it needed a pre-join across `collectAuthorizeExprs`' callers. The OR fold was already inert, because every array reaching `gateFilterText` is one declaring source's own note list, so the change is the fold operator plus deleting `assertAtMostOneAuthorizeGate`. The author's text still reaches the compiler unnormalized, which is what the grammar promises. Repeats make four checks cross-note: `duplicate_given`, `duplicate_field_path` and `mixed_scope_body` over one declaring source's terms per route per group, never over `AuthorizeMap`'s flattened groups, which would refuse a query-source base and its composite member sharing a given. A deny-all sharing a source with any sibling is the one cross-route refusal. `#(source-authorize)` is a rule about the caller. It inherits through `extend`, because a gate an extension can shed is no gate at all, and own-wins-over- ancestor is per route so an own note on one route cannot shed the other's. It grafts as its own entry and ANDs with the row-level gate; there is deliberately no spelling for admitting a caller while skipping the row filter, so the admin escape hatch stays two sources over a locked base. The new route opened two fail-opens that are closed here. The caller rejecter matched only a bare `authorize`, so caller-submitted Malloy could mint a `#(source-authorize)`; the near-miss family was exact-match, so `source_authorize`, `sourceauthorize` and `authorize-source` all loaded inert. The fail-closed `["false"]` sentinel is synthesized on the `authorize` route only, or one unreadable struct would deny twice and double its metrics. `false` means the same thing on both routes, and `get_context` now drops a source gated that way rather than reporting something no caller can query. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Nathan Huff <nathan@credibledata.com>
…mar-drop-partition Signed-off-by: Nathan Huff <nathan@credibledata.com>
Review findings on the source-level route.
`get_context` dropped a deny-all source from its sources loop but not from
the queries loop, and `cardFor` minted a card from a bare name whether or not
the source had context. A `{target_type: "view"}` enumeration therefore
resurrected a card for a source gated `false` whenever the model declared a
query over it. No rows: the path reports rather than serves. But the name
leaked, and the tool's own contract line had just promised it could not. The
dropped set is computed once now and both loops plus `cardFor` read it, so the
promise is true rather than nearly true. The five existing drop tests all
stubbed `getQueries` empty, which is why the path was uncovered.
The fail-closed sentinel now denies on both routes. Gating it to `authorize`
rested on the other route's walk reaching the same failing branch, and it does
not when the struct owns an `#(authorize)` note: `gateExprsForOwnAnnotations`
returns before `ancestorGateExprs` runs, leaving the source-level walk to
degrade to "no gate". The cost that justified the gating is not real either,
since the rejection metrics count once per graft attempt rather than once per
entry. The `query_source`-base branch stays authorize-only, where
`resolveQuerySourceBase` takes no route and the parity actually holds.
`GateEntry.route` joins the dedup key it was documented as protecting, or a
same-text pair across the two routes collides and one route's entry is lost.
A doc comment on the fold taught `#(authorize) $ROLE = 'admin' or org_id in
$GROUPS` as the admin override, which the grammar refuses as
`compound_boolean` a few files away, and two entry-point docs still described a
source's own list as an OR disjunction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Nathan Huff <nathan@credibledata.com>
…m deny A three-model panel and a design review on the same diff. `$GROUPS in 'finance'` validated and then failed to compile. The parser normalizes a source-level term to literal-first so one shape reaches the checks, but the graft path lifts the author's original text, and Malloy has no array-in-string membership. `=` reads either way round and keeps the swap; `in` does not, and now draws a named refusal with the literal-first rewrite. `duplicate_field_path` compared the authored spelling, so ``region`` and `` `region` `` named one column twice and passed. It compares the parsed segments now, which are already unquoted. The deny-all drop was a package-wide set keyed on a bare source name, so a deny-all in one model blacked out an open source of the same name in another. Keyed by model path and name now, leaving the neighbouring first-model-wins rule alone. A retired marker reachable only through `annotations.inherits`, its declaring base absent from `modelDef.contents`, survived the scan and would serve every row. The file-level readers also take both note keys now: model-level annotations only ever land in `notes` on this compiler version, so that half fixes nothing observable today and is kept for the repo's read-both invariant rather than for a live gap. The fail-closed sentinel is uniform across all four sites. Gating it per route needed both routes to reach the same failing branch, which two sites do not: own notes short-circuit the walk per route, so the other route reached the unreadable path alone and contributed nothing. The divergence reproduces on real IR; the model carrying it fails package load first, so this closes a structural gap rather than a reachable leak. The cost is one duplicate W1 at load, not nothing — the per-query metrics are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Nathan Huff <nathan@credibledata.com>
The drop key joined model path and source name on a literal NUL, which is enough to make `file` call the module data and to make plain `grep` skip it in silence rather than report no matches. That is the trap `model.ts` already sets for anyone searching this repo, and reproducing it on the path that decides whether a gated source is listed would hide the next fail-open from the sweep that would otherwise find it. The escape spelling separates identically at runtime and leaves the file readable as text. `reversed_in_operands` shipped without a row in the refusal table; a reader hitting it had the message and no entry to look up. The duplicate-given and duplicate-field-path refusals are scoped to one route, and the validation paragraph now says so rather than reading as cross-route. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Nathan Huff <nathan@credibledata.com>
…mar-drop-partition # Conflicts: # RELEASE_NOTES.md # packages/skills/package.json Signed-off-by: Nathan Huff <nathan@credibledata.com>
The release note said the repeated form was a load error in every version from 0.2.0 through 0.2.7; 0.3.0 has since shipped and still carries `assertAtMostOneAuthorizeGate`, so the range was about to read as if a version in between had reinterpreted it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Nathan Huff <nathan@credibledata.com>
The legal block showed one source-level term, one row-level term and a two-term body, so a reader asking whether a repeated annotation, a deny-all, a joined path or a caller rule beside a row rule was legal had to infer it from the refusal table. Each addition was run through `parseAuthorizeGrammarBody` and `assertAuthorizeGrammarTermsCoherent` rather than read off the grammar. The two-route example deliberately references `\$GROUPS` on both, because that reuse is legal across routes and refused within one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Nathan Huff <nathan@credibledata.com>
It referenced an `orders_base` and an `accounts` that appear nowhere in the block, while every sibling example is self-contained, so the one example about a joined path was the one a reader could not paste. It also now names the refusal a fan-out join draws, since that is the whole reason the path case is worth showing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Nathan Huff <nathan@credibledata.com>
…n be re-opened A source declaring no `#(authorize)` of its own inherits its ancestor's, so over a `#(authorize) false` base, omitting the annotation inherits the lock rather than lifting it. Every other legal body references a given, which left no way to spell "this extension is deliberately open" — the locked-base-plus-curated- extensions pattern could lock a base and narrow it, but never re-open any part of it. A whole body of exactly `true` now parses as `admit_all`, mirroring `false`: either route, never a term inside an `and`, grafted as `where: (true)` by the same path that already compiles a bare `false`. `true` is the only token in the grammar that turns a gate off, so it carries guards. `admit_all_with_sibling` refuses it beside another note ON ITS OWN ROUTE, where the notes AND into one body and `true and x` reduces to `x`. It is deliberately NOT cross-route, unlike `deny_all_with_sibling`: `true` sheds only its own route's inherited gate, so `#(authorize) true` beside `#(source-authorize) 'finance' in $GROUPS` is live rather than dead text. Deny is checked first, so one route carrying both sentinels reports the fail-closed cause. A caller-minted `#(authorize) true` is still a 400, and every own declaration is counted at load on `publisher_authorize_admit_all_total`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Nathan Huff <nathan@credibledata.com>
…lement-wise
Three findings from an independent review of the admit-all sentinel.
The `authorize` array flattens gates from different declaring sources that AND
together, so `["true", "org_id in $GROUPS"]` is a real wire shape from a
query-source base plus a composite member. The api-doc told a consumer to read
`"true"` as ungated, which grants where the gate denies; it now says a list is
unrestricted only when `"true"` is its sole element.
`publisher_authorize_admit_all_total` counted entry points rather than
declarations, the opposite of what its own doc claimed. `authorizeOwnNotes` is
presence-based and Malloy copies a base's note onto a plain `extend {}`
derivation by reference, so one declared `true` ticked once per inheritor. It
reads `attributedAuthorizeOwnNotes` now, passed separately rather than swapped
in, because narrowing the presence map at the `"false"`-filter call site would
turn a load refusal into a fail-open. It also fires after the coherence checks,
so a refused model no longer ticks for a gate that never serves.
`true` cannot re-open a query-source derivation: that collection recurses into
the base unconditionally, so an own gate is additive there rather than a
replacement, and the base's `false` stays in the conjunction. Fail-closed, and
now pinned by a test and stated where the docs previously contradicted
themselves about it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Nathan Huff <nathan@credibledata.com>
Both are set-valued, which is what fixes `in` as their operator, so a reader copying the block needs the declaration to copy with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Nathan Huff <nathan@credibledata.com>
…mar-drop-partition Signed-off-by: Nathan Huff <nathan@credibledata.com>
The scaffolder's watched paths include packages/skills/package.json, which this branch bumps, so a release would skip the scaffolder and stay green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Nathan Huff <nathan@credibledata.com>
…mar-drop-partition # Conflicts: # RELEASE_NOTES.md # examples/governed-analytics/README.md Signed-off-by: Nathan Huff <nathan@credibledata.com>
authorize.ts and authorize_grammar.ts each declared their own copy of the same two route literals, and parseAuthorizeGrammarBody decides row_level_term_in_source_authorize against the grammar module's copy — so a value changed in one file alone would move enforcement with every test still green. Both copies now read one zero-import module, which also keeps authorize.ts bundlable into the package-load worker. No behavior change. Signed-off-by: Nathan Huff <nathan@credibledata.com>
…thorize) deprecated `#(authorize)` is enforced as a row filter but its name says nothing about rows, so authors reach for it wanting "who may reach this source at all" — the question #1163 split out as a separate route. Each behavior now has a name that says which question it answers: `#(row_authorize)` (which rows may this caller see?) and `#(source_authorize)` (may this caller reach this source at all?), renamed from `#(source-authorize)` before it has ever shipped, so only one spelling is ever published. snake_case because Malloy uses `_` in 40+ multi-word tag names and `-` in none, and because a hyphen is safe only in the bracketed route position: in bare-tag position `-` is the delete operator, so `#source-authorize` parses as a define plus a deletion, with no error. `#(authorize)` keeps loading and keeps behaving identically — published posts and customer models carry live examples that cannot be recalled. It is an alias, not a third route: `authorizeNoteContent` canonicalizes at the single choke point, so own-wins, the ancestor walk, per-route grouping, coherence bucketing and the wire split all inherit the right answer. Added as a third route instead, an extension's deliberate `#(row_authorize) true` over an `#(authorize)` base would inherit the base's lock and stay locked, with no error — covered end to end in conformance Group F. The caller-forgery rejecter is stem-based and would have failed OPEN on every new spelling, so its pattern moves in the same commit, with a test asserting it stays a superset of what the classifier treats as a gate. Also: the hyphenated spellings are refused near-misses naming the snake one rather than silent aliases, so a later audit cannot under-report by grepping; the legacy-string and misplaced-annotation messages now quote the author's own spelling back and name the canonical one in the remedy; and a load-time notice plus publisher_authorize_deprecated_spelling_total reports remaining `#(authorize)` use, per declaring source, never per inheritor. BREAKING: publisher_authorize_admit_all_total's `route` label is now `row_authorize`/`source_authorize`; a dashboard matching `route="authorize"` needs updating. Signed-off-by: Nathan Huff <nathan@credibledata.com>
Every teaching example across docs, skills, examples and the hammer scenarios moves to a canonical spelling. docs/authorize.md gains a section on the deprecated `#(authorize)` alias — what it means, why mixing the two spellings on one source is safe, and why the hyphenated forms are refused rather than aliased. RELEASE_NOTES' unreleased entry is rewritten rather than appended to: it announced `#(source-authorize)` as a new route, and that spelling never shipped. The shipped sections below it are history and stay as written. api-doc.yaml prose only — `Source.authorize` still carries both the canonical and the deprecated row-level spelling, and `Source.sourceAuthorize` is unchanged, so `generate-api-types` produces no diff at all. A third field would force every consumer to union two, and one that forgot would read a gated source as ungated. tests/fixtures/authorize-compile stays on `#(authorize)` on purpose, commented as the live canary that the alias still loads and still gates. Signed-off-by: Nathan Huff <nathan@credibledata.com>
The rename rests on a claim about Malloy, not about publisher: that the compiler routes `#(row_authorize)` and `#(source_authorize)` in every sigil, block and bracket spelling, and routes neither the hyphenated nor the case variant there. Asserted against `Annotations.forRoute` directly, since a spelling that silently stopped routing would turn a locked source into one that serves every row, load-clean. Alongside it, the forgery rejecter is exercised through the real query path rather than in isolation, for all three recognized spellings, asserting the author's gate is still the one in force afterwards. Signed-off-by: Nathan Huff <nathan@credibledata.com>
Signed-off-by: Nathan Huff <nathan@credibledata.com>
Signed-off-by: Nathan Huff <nathan@credibledata.com>
…mar-drop-partition # Conflicts: # RELEASE_NOTES.md Signed-off-by: Nathan Huff <nathan@credibledata.com>
…mar-drop-partition Signed-off-by: Nathan Huff <nathan@credibledata.com> # Conflicts: # RELEASE_NOTES.md
…mar-drop-partition Signed-off-by: Nathan Huff <nathan@credibledata.com> # Conflicts: # packages/server/src/mcp/tools/get_context_tool.ts # packages/server/src/service/source_extraction.ts
…mar-drop-partition Six conflicts, all resolved toward keeping both sides' intent: - model.ts: main replaced the entry-point gate key's literal NUL separators with \x1f (NUL made the file read as binary, so grep skipped every line of it); this branch had added `route` to that key. Kept both — SEP separators with the route component. - materialization_eligibility.ts and api-doc.yaml: main added the `given_in_persisted_query` refusal; this branch retires `#(partition)`. Kept the new refusal, dropped the partition one. - security-posture.md: main made the authorize bypass require a secret; this branch renamed the annotation. Kept main's semantics. - skills_bundle.json is generated — regenerated rather than hand-merged. - RELEASE_NOTES.md: both sides added [Unreleased] entries; kept both. Generated API types regenerated for main's new `configEtag` and `given_in_persisted_query`. Suite: 4042 pass / 3 skip / 0 fail. Signed-off-by: Nathan Huff <nathan@credibledata.com>
…ow filter The plain word moves to the question it reads like: may this caller reach this source at all. The row filter takes the name the surrounding world already uses -- Looker and Omni both say access_filter, Spring Security splits @PreAuthorize from @PreFilter. This is a semantic flip, not a rename: #(authorize) shipped as the row filter in 0.2.0 through 0.4.1. There is no alias and no deprecation path. #(row_authorize) and #(source_authorize), which only ever existed on this branch, are gone; both stay listed as near-misses so a model written against them is refused rather than loaded inert. The name now declares the scope and nothing is inferred from the body: #(authorize) 'literal' <op> $GIVEN, and the true/false sentinels #(access_filter) field_path <op> $GIVEN so parseTerm's row-vs-source decision stops being a routing input and becomes a validation check. The mirror refusal is new and is a security fix, not tidiness: a source-shaped body on the row route is legal today and grafts as a constant predicate, so #(row_authorize) 'finance' in $GROUPS answers a non-member with 200 and zero rows -- the fabricated answer this flip exists to stop, reachable right now on the route nobody was looking at. With each route admitting exactly one scope, mixed_scope_body is fully subsumed and both sentinel-sibling checks become single-route. AUTHORIZE_TAG_LIKE widens to cover both stems in the same change: it is the caller-forgery rejecter, and a stem that does not match access_filter is a forged-gate bypass. parseAuthorizeGrammarBody's route parameter loses its default for the same reason -- a caller omitting it would validate a lock body under filter rules. On the wire, Source.authorize now carries the lock and Source.accessFilter the filter, so the field named authorize means what the annotation named authorize means. sourceAuthorize is deleted; it was never released. The 403 lands in the next commit. Until then the lock still grafts, which is why the locked-base fixtures here assert zero rows. Signed-off-by: Nathan Huff <nathan@credibledata.com>
`#(access_filter) false` grafts as `where: false`, and `SELECT sum(salary) ... WHERE FALSE` is one row of NULL while `count()` is `0`. On the row route that is honest — no rows, so zero. On a gate that decides whether the caller may reach the source at all it is a fabricated answer about data they were refused. So the lock stops grafting and starts refusing. This is a restoration, not an invention: a whole-source admit-or-403 gate shipped until #1045 deleted it, decided there by a synthetic one-row DuckDB probe. It comes back read from the compiled condition instead, as `#1017`'s own commit message prescribed for the row route -- "a positive allowlist over the compiled condition ... read from the IR rather than the annotation text". The seam is a third arm on `resolveGateShape`, keyed on `entry.route`. Everything above the split -- the lift, the given-id expansion, the model-surface re-check -- is the same work for both routes and is not done twice; only the answer differs. `decideLock` (new, pure, zero I/O) then walks the lifted `FilterCondition` with a positive allowlist: `()`, `and`, `true`/`false`, `inGiven`, and `=` with one given kid and one string-literal kid in either order. An unknown node kind, an unresolvable given, an unset given, a wrong-arity value and a throw all deny. Reading the IR rather than the text is what makes this small. Malloy has already decoded the string literal (`stringLiteral.literal` arrives unquoted and unescaped -- a TS evaluator would need Malloy's own escape rules, and one mistake is a gate that silently never matches), already type-checked the comparison (a `string` given with `in` is a 424 at load, which the text route cannot see at all), and already carries the operator that `parseTerm` discards. The lift is also already paid for, at load and per-entry at request time. Both call sites -- the pre-compile gate and the entry-point walk -- now share one `denyUnlessAdmitted`. They were byte-identical before and a comment already said so; more to the point they are not two decisions. The early gate exists to reach the SAME answer the compiled backstop reaches, and a divergence between them is a schema oracle. Consequences worth knowing, each with a test: - `/compile` truth-evaluates the lock. If reaching a source is what the word means, reading its SQL is reaching it. The `includeSql` + non-satisfying given hole this suite used to record as an accepted residual is closed. The cost is real: an author outside the group can no longer compile-check a locked source. `#(access_filter)` keeps deciding on presence, not value. - The no-schema-oracle guarantee comes back. The lock is decided before the caller's query compiles, so a denied caller probing a non-existent field gets the 403 and not the field error. - A hidden AND locked source still answers 404, not 403: the lock throws from the same two sites `rejected` already threw from, so it is inside `denyHiddenAsNotQueryable` by construction rather than by a new audit. - Storage/pre-aggregation routing stays blocked for a lock-only source. The serve shape carries no annotation bytes, so a routed query is one the lock could never run against, and there is no post-hoc undo. - Comparison is exact and case-sensitive rather than the warehouse's. A source-shaped gate on the old row route was compared by the warehouse, so a MySQL tenant's case-insensitive default admitted `'Finance'` against `["finance"]`. Fail-closed, but a behavior change. - A `null` given binding denies rather than throwing; a thrown evaluator would be a 500, which is a different response class from the 403 every other unadmitted shape gets. Lock denials book a new `publisher_authorize_lock_total` rather than joining `publisher_authorize_row_level_total{decision="denied_by_gate"}`, whose documented meaning is the fail-closed "could not apply the gate" case operators alert on. Folding routine refusals into it would fire an existing alert on ordinary traffic. One correction to the review that prompted this: the gate-shape memo was called a live bug on the grounds that the two routes share a key. They do, but the route is applied after the memo, so a shared entry cannot produce the wrong shape today. `entry.route` is in the key as a margin, not a fix, and the test pins the property that actually matters -- the shape comes from the route, not from whatever the memo holds. Signed-off-by: Nathan Huff <nathan@credibledata.com>
The prose still argued the design this branch replaced: `#(authorize)` as a deprecated alias, both sentinels legal on either route, every denial a 200 with zero rows, and `/compile` documented as a schema exposure the lock now closes. A sweep of the spellings would have left all of that in place, so `docs/authorize.md` is rewritten rather than renamed -- the intro, the grammar table, the route section (which absorbs "joins are not gated", because that reads as a contradiction once the plain word means "may you reach this source"), the worked example's verdict table, the locked-base idiom, and Enforcement. The deprecated-spelling section is deleted. The two `/compile` warnings are deleted rather than edited: they documented an exposure that no longer exists on the lock route. `docs/security-posture.md`'s "rows are protected, the schema is not" was written when every gate was a filter and read as a claim about the whole feature. It is now scoped to `#(access_filter)`, with the lock named as what closes it -- which is the actionable half for anyone reading that page to decide what to write. The release note leads with the migration rather than the naming, because the two halves fail differently: a row-shaped body under `#(authorize)` is refused at load and cannot be missed, while a caller-shaped one keeps loading and silently starts answering 403. The second is the one that needs a grep, and it is called out as such. Rollback is stated too -- no earlier release knows `#(access_filter)`, and an unknown route loads clean and serves every row, so models roll back before the binary does. Skills: the `malloy-model` access-control section is rewritten on the same lines, and the four skills that cross-reference it are pointed at the new name. Bundle regenerated, `@malloy-publisher/skills` bumped to 0.1.21. The shipped `examples/governed-analytics` gate moved with the flip in the first commit of this branch; this one carries the README and the module comments that still called the filter route "authorize". Signed-off-by: Nathan Huff <nathan@credibledata.com>
The scaffolder writes an index.malloy into every new package, which breaks two
shipped workflows for users who never chose the convention (kylenesbit).
**The xlsx recovery probe was unrunnable.** model.custom.xlsx.malloy is the fix
for the most common spreadsheet failure, a header that is not on row 1. It tells
the user to add a `_probe` source to the model file and run `_probe -> peek`,
which after the convention is refused either way: the model file is off the
surface, and against index.malloy the probe is not in the export closure. The
template now puts the probe in index.malloy and names it in `export { ... }`, and
says why -- raw SQL has to be part of a model, and the surface file is the only
model a query can be addressed to.
**A dashboard added later is silently withheld.** A dashboard file is not
something an index.malloy can export, so dashboards/*.malloy is never served,
with only a load warning whose remedy is to abandon the convention. Every
scaffolded package now carries that in its generated AGENTS.md, before the author
builds one, because the fix is a different curation shape for the package rather
than an extra line at the end.
malloy-dashboards gets the same warning as step 0 of the build sequence, and its
troubleshooting bullet covered only one direction: adding index.malloy to a
package that has dashboards. The other direction, adding a dashboard to a package
that already has index.malloy, is now the common case, since every scaffolded
package has that file. Bundle regenerated.
Signed-off-by: James Swirhun <james@credibledata.com>
**api-doc.yaml** (Sha-Bang, nit): the queryableSources description still said "When `explores` is absent there is no curated surface, so both modes are equivalent (everything queryable)", which the deprecation block four lines below now contradicts. It is in the published spec, so it is the version a client generator reads. Qualified with the root index.malloy case rather than deleted, since the equivalence still holds where there is genuinely no surface. **docs/givens.md** (kylenesbit): the execute_query sample still posted to governed-analytics with "modelPath": "orders.malloy", which this PR's conversion takes off that package's surface, so the copy-pasteable call 404s. The introspection link above it pointed at the same file. Both now name index.malloy and say why, with a line that givens declared in an imported file reach the surface unchanged. authorize.md, row-level-access.md, discovery-and-access.md and the example README were already converted; this one was missed. RELEASE_NOTES and discovery-and-access.md pick up the behavior changes in this batch: the malformed explores now fails the load rather than warning, renaming is not an opt-out, and notebooks do not count as something withheld. Signed-off-by: James Swirhun <james@credibledata.com>
A request that declares its own name for a gated source — `source: s is
locked extend {}` — reaches an entry point the model never declared, so the
gates keyed by source name matched nothing and two things went wrong at once.
A caller the lock ADMITS was refused. With no gate collected for the run
target, the request fell to the laundering check, which refuses any
request-declared entry point whose chain reaches a source carrying gates.
That is right for a row filter, which has nowhere to graft onto an ephemeral
entry point, and wrong for a lock: a lock is decided, not attached, so once
it admits there is nothing left for an alias to strip. Measured before the
fix, the filter route served that alias and the lock route refused it — and
`#(authorize) true`, this release's deliberately-open marker, refused it too,
which made an open source stricter than an ungated one. The chain walk now
collects the gated bases it reaches instead of refusing on sight, and the
caller decides each one; anything that is not an admitting lock still denies,
so the fail-closed direction is unchanged.
A caller the lock REFUSES could still read the compiler. The pre-compile gate
resolves the surface run-target name, which is the alias, so nothing was
decided before compilation and Malloy answered first: `group_by:
no_such_field` returned "not defined" for a source the caller cannot read,
one bit at a time. That is the schema oracle the early gate exists to close
and the release notes claim is closed. It now walks the request's own
derivations to the model-declared bases and decides their locks first, on
`/compile` as well as `/query` — `/compile` answers WITH diagnostics, so
ordering is the whole guarantee there, and text that only declares sources is
walked too. The walk never denies on its own: a chain it cannot read gates
nothing and the post-compile check, which is the fail-closed one, still
decides it.
Two orderings this has to respect. Caller text carrying an annotation is left
to the forgery rejecter, whose refusal is the specific one and whose counter
operators read — asked with that rejecter's own predicate rather than a
near-copy, since a narrower one lets a spelling it misses answer elsewhere.
And a refusal converts on the base actually gated, so a hidden source stays a
404 rather than a 403 naming it; the callers' own conversions read surface
syntax and cannot see through the alias.
Signed-off-by: Nathan Huff <nathan@credibledata.com>
…mar-drop-partition # Conflicts: # RELEASE_NOTES.md
…s it Running this against a live server showed the failure I described when I added brokenSurfaceWarnings is not the one an author actually meets, and the docblock, the test comment, the release note and the docs all repeated the wrong mechanism. What a run establishes: - first load with a broken surface file fails the whole package. It is absent and named in /status loadErrors. Nothing serves an empty surface. - every author-facing reload -- the chokidar watcher, MCP reload_package, REST ?reload=true -- goes through Environment.loadPackage, which keeps the last good compiled model serving and records a staleCompileErrors entry. /status reports the package with stale: true AND the compile error, and the surface never empties. REST answers 424 with the diagnostics on top of that. So the case is not "silent", as claimed: those paths report it better than a warning would. What remains is reloadAllModels called directly, from the materialization and manifest rebind paths, which install a placeholder for a model that fails and never reach loadPackage. That is the one way to get a package that is serving, is not stale, and exports nothing, and it is the one path where nothing else says so. The warning is worth keeping for that path and is left as it is. Only the claims around it change, plus the spec comment, which reached the state by calling reloadAllModels directly and read as though that were the ordinary edit path. Signed-off-by: James Swirhun <james@credibledata.com>
…ex-malloy-surface
The base moved and the PR went CONFLICTING, which is worse than it looks:
GitHub stops dispatching CI on a conflicted PR, so the checks go quiet and
read as green when nothing ran. DCO was the only check on the last head.
Three conflicts:
- AGENTS.md: the governance bullet. The base is right about the annotation
(`#(authorize)` is the lock that decides whether a caller may query a
source at all; `#(access_filter)` decides which rows they see), and this
branch is right about the surface (a package's `index.malloy`). Kept
both. kylenesbit flagged exactly this line in review and it is resolved
here rather than on the branch, because the wrong term came in on the
base and main already reads `#(authorize)`.
- docs/discovery-and-access.md: this branch rewrote the file for the
convention while the base corrected the annotation split and added what
`/compile` does with a lock. Took this branch's rewrite, which already
used the corrected split, and carried the base's `/compile` semantics
into it: a lock is truth-evaluated there, so a refused caller gets a 403
and no SQL, while `#(access_filter)` is not, because it decides rows and
`/compile` returns none.
- skills_bundle.json: generated, so regenerated rather than hand-merged.
It picks up the skill the base added (81 entries, 40 skills).
Also regenerated the API types after the api-doc merge; the committed
spec gained the base's StrippedTerm and EligibilityRefusalReason, and the
generated file was stale against it.
Verified on the merged tree: typecheck and lint clean, 4166 server unit
tests pass with the same 35 known config.spec failures, and the
index_convention and dashboards integration suites pass. One perf test
from the base ("stays linear on the input that made the old one
quadratic") fails under a loaded full run and passes in isolation.
Signed-off-by: James Swirhun <james@credibledata.com>
|
Thanks both — all eleven items are in, and four of Kyle's were defects in fixes I had pushed the day before, which is the useful kind of catch. Branch was CONFLICTING against the base, which matters more than it reads: GitHub stops dispatching CI on a conflicted PR, so the checks went quiet and looked green when nothing had run (DCO was the only check on the previous head). Merged the base in at 37ca95c and CI is running again. @Sha-Bang — replies inline on each threadBoth blocking ones are fixed: a malformed @kylenesbit — all sevenThe scaffolder exposure, both workflows:
The four warnings:
One correction to my own earlier claimI built and ran this against a live server, and it showed that the broken- The warning still has a home — the materialization and manifest rebind paths call Verified against a running server, not just testsThree scratch packages plus a second server for the boot case: convention package lists only Typecheck and lint clean, 4166 server unit tests pass against the same 35 known Also resolved the @Sha-Bang on the contract note: understood, and the manifest-reading blind spot is noted — that side is being fixed on your end, so nothing changes here for it. |
The other half of Sha-Bang's second blocking comment, which I left out of fa3a952 because it needed state that did not exist. It does not need much. Deleting or renaming a root index.malloy resolves to no surface, which is an ordinary uncurated package, so every source that file was withholding is listed and answers by name again and nothing anywhere reports it. It is the only curation change that goes unremarked: a surface that APPEARS warns at load, a malformed explores refuses the load, and a broken surface file fails the reload and is reported stale. An uncurated package has nothing to say about itself, which is exactly the problem -- it looks identical to one that was never curated. resolveExplores cannot see this, and that is why the warning is not there: it is a pure function over the manifest and the current file list, so it cannot tell a package that never had an index.malloy from one that had it until the last save. The Environment can, because the reload path holds both the outgoing package and the incoming one, so the comparison goes there and the message is handed to the new package to carry. Only `undefined` counts, and the distinction is the point: `undefined` is "no explores key and no index.malloy", which is what deleting the file resolves to and nobody asked for, while `[]` is an author writing the documented opt-out, who already gets a message saying so. Said once, on the reload that caused it, because it reports a change and not a state -- a later reload of an already-uncurated package has nothing to report, and the spec pins that it goes quiet again. Curation, not access control, and the message and comments say so: what widens is what is listed and what answers by name. A source gated by #(authorize) stays gated; hiding was never denying. The new integration spec fails without the comparison wired in. Signed-off-by: James Swirhun <james@credibledata.com>
…rface Signed-off-by: James Swirhun <james@credibledata.com> # Conflicts: # RELEASE_NOTES.md # api-doc.yaml # docs/README.md # docs/authorize.md # docs/discovery-and-access.md # docs/row-level-access.md # examples/governed-analytics/README.md # packages/server/src/mcp/skills/skills_bundle.json # skills/malloy-publish/SKILL.md
…rface Signed-off-by: James Swirhun <james@credibledata.com> # Conflicts: # RELEASE_NOTES.md
0.1.22 and 0.0.18 shipped in the 0.6.0 release, so this PR's skill and scaffolder changes need versions past them or a release would skip both. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: James Swirhun <james@credibledata.com>
In a curated package, a query sent to a .malloynb path bypassed the surface entirely. The notebook's own view counted as curated, and that view includes everything it imports, so `run: base_source` addressed to a notebook read a source from a hidden file. The same request addressed to index.malloy answered 404. A notebook now passes the file-level check, since notebooks are always listed, but only the package surface admits there: the notebook's own sources and queries admit nothing. Its cells are unaffected, because they run through executeNotebookCell, which never reaches this gate. That is the line: text saved on the server (cells, exported named queries) may read what its file imports; text sent in a request is held to the surface, whatever path it is addressed to. The test replaces the one that pinned the exemption. It fails on the old code (the ad-hoc query resolves) and passes on the new. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: James Swirhun <james@credibledata.com>
… source A dashboard can be listed and compile cleanly while a tile reads a source that only an unlisted file declares. /compile is exempt from the query boundary, so the author sees nothing wrong until the tile answers 404 after publishing. The usual cause is an import: listing a file publishes what it declares, not what it imports. The dashboard lint now asks each served dashboard's model, through the query endpoint's own pre-compile gate, whether each tile would be refused, and reports each one that would as a package warning with severity error, naming the tile and both fixes. Using the gate itself keeps the lint and the endpoint from disagreeing. It refuses only a target it can pin from the text, so a tile it cannot read is not reported. It runs wherever the dashboard lint runs: every load and reload, including reload_package and the reload after a dashboard save. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: James Swirhun <james@credibledata.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: James Swirhun <james@credibledata.com>
…issing one
The pre-compile gate refuses ad-hoc text only for a name the model
declares, and it echoed that name back ("No queryable source
\"helper\""). A name that does not exist reached the compiled
backstop's generic "Query target is not queryable." instead. Both are
404s, but the different words told a caller which hidden names are
real, which is what the 404 exists to hide.
Found by running the built server against a probe package: on
index.malloy a declared-but-unexported source and a nonexistent one
answered differently. The previous commit made notebook paths reach
this gate too, so a notebook's imports were exposed the same way.
The ad-hoc branch now uses the backstop's words. The named-source
branch is unchanged: it echoes whatever the caller sent, hidden or not.
The malloy-source-unreachable skill already promised the two cases are
indistinguishable; its 404 row now lists the generic message too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: James Swirhun <james@credibledata.com>
The "surface widened" warning was computed only when a reload swapped in a new Package. Materialization runs and manifest rebinds reload the same Package in place, and that path never compared before and after. So deleting index.malloy and letting a scheduled persist build reload the package exposed every hidden source with no warning. A later full reload then read the already-widened surface as "before" and stayed quiet too. The comparison now lives on Package (noteSurfaceChangeFrom) and both paths call it: reloadAllModels with its own surface from before it re-read the tree, and Environment with the surface of the package it replaces. A server restart has no "before", so it still cannot report this; the release note says so. Also moves the surfaceIsIndexModel() doc-comment back above that method. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: James Swirhun <james@credibledata.com>
…ady has
The xlsx model's troubleshooting comment told the user to append a probe
source plus `export { sales, sales_probe }` to index.malloy. The
scaffolded index.malloy already exports `sales`, and Malloy keeps one set
of exported names per file, so the second statement fails with
"'sales' already appears in an export statement" and the reload the
probe needs never compiles.
The comment now says to add the probe's name to the existing export
line. An e2e test takes the snippet from the generated comment, applies
it that way, and compiles it through the server's /compile endpoint; it
also pins that appending a second export is refused.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: James Swirhun <james@credibledata.com>
docs/dashboards.md still said a package with no `explores` has curation off. With the convention, a package whose root holds an index.malloy is curated with no `explores` key, and every scaffolded package has one. A dashboard author following the old sentence would expect a suggest dropdown to work and get a 404 instead. It now names both ways a package curates, and says a package curated only by index.malloy withholds every dashboard until an `explores` names them. In api-doc.yaml, the dashboard-list description now names the same two triggers, and the package `warnings` description lists the two warnings this branch adds: a surface whose every model failed to compile, and a package that lost its surface on a reload. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: James Swirhun <james@credibledata.com>
Over REST, a query to a model path that does not exist answered
"<path> does not exist", while a model that exists but is off the
package's surface answered 'No queryable model "<path>".'. Both were
404, but the text let a caller tell a real hidden file from a missing
one by guessing paths. MCP already answered the two the same way.
The missing-model case now uses the hidden-model text, which makes the
scaffolded AGENTS.md claim ("a 404 that reads the same as a model that
does not exist") true over REST as well. An integration test pins both
bodies. The status code is unchanged; the release note covers the text
change on an existing endpoint.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: James Swirhun <james@credibledata.com>
`explores` and `queryableSources` are deprecated in favor of index.malloy, but two of their uses have no replacement: `queryableSources: "all"`, which curates listings without refusing queries, and an `explores` naming several files. The second is also the documented way to serve dashboards beside an index.malloy. Both got a deprecation warning on every load that could not be silenced, so following the documented dashboard fix earned a "deprecated" notice. A load-time deprecation now goes only to the uses the convention replaces: a one-file `explores` and `queryableSources: "declared"`. The spec keeps `deprecated: true` on both fields and says the two remaining uses stay supported. Docs, the malloy-publish skill (bundle regenerated) and the release note say the same. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: James Swirhun <james@credibledata.com>
One said the malformed-explores refusal "restores the behavior this key had before" the convention; the other described how a fact about the reload paths was discovered. Neither tells the next editor anything the surrounding comment does not. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: James Swirhun <james@credibledata.com>
Four conflicts. Two were mechanical, two were a decision. Both independent package versions: main is ahead of this branch on each, and npm now carries exactly the versions this branch introduced -- skills 0.1.23 and create-malloy-package 0.0.19 both published. Main's 0.1.24 / 0.0.20 are the ones still ahead of the registry, so they win. Keeping this branch's numbers would have left them level with npm, which is the case the release workflow skips silently while reporting success. RELEASE_NOTES.md: main added two `[Unreleased]` sections of its own. All three are kept, this branch's first, since they are separate entries in one release. skills_bundle.json is generated, so it was regenerated rather than hand-merged: 81 entries over 40 skills, one more skill than this branch had. `packages/server/src/api.ts` is gitignored and generated from the spec main changed, so the merge alone leaves it stale and typecheck fails on a file the diff does not show. Regenerated. Verified with the project's own commands: `typecheck:server` clean, and `test:unit` 4251 pass / 0 fail over 186 files. That last one matters here because main's index.malloy change (#1206) rewrites the governed-analytics package this branch's continuation spec loads and queries. Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
…ooks and the model GET can read (#1236) * fix(server): list every dashboard, and let its tiles read only the package 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> * fix(server,sdk): show a model only when it is on the surface, and only 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> * fix(server): hold notebook cells to the package surface, and show only 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> * fix(server): deprecate every use of explores, and say each warning in 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> * docs(skills): teach one rule, index.malloy is what dashboards and notebooks 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> * docs: describe the surface as every route sees it, and note the change - 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> * docs: keep queryableSources marked deprecated, as #1206 left it 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> * fix(server): prune the rest of modelDef in the model GET 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> * fix(server): withhold text that names a hidden source, and say what a 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> * fix(sdk): keep the dashboard catalog when one published model fails to 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> * docs(server): fix two comments that still say dashboards are withheld 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> * fix(server): close the remaining hidden-source leaks in the model, notebook 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> * test(server): cover named queries in the model GET and query route 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> * fix(server): publish an untagged dashboards/ file that explores lists, 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> * docs: say explores still works as before, only deprecated 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> * docs: describe explores without reference to earlier behavior A reader with no history gets nothing from "as before"; say what the key does. Signed-off-by: James Swirhun <james@credibledata.com> --------- Signed-off-by: James Swirhun <james@credibledata.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Revives the idea behind #988, which was closed as stale rather than rejected. This is not that branch rebased. It is a fresh implementation against current
main, and it makes the one design decision #988 left open in the opposite direction.Base note: this stacks on #1163, so the diff here is only its own 10 commits. It cannot merge before #1163 does. #1163 is what makes the three-answer table below true.
What this does
A package's published surface is its
index.malloy. Put that file at the package root,importyour models,export { … }what you publish. What it exports is what Publisher lists and what callers may query. Nopublisher.jsonkey at all.orders_stagingstill compiles, and other models can import, join and extend it, but it is not listed and a direct query against it answers 404.create-malloy-packagescaffolds the file, so a new package is curated from its first boot.Where this departs from #988, and why
#988 made the convention curate listings only — it hid sources but never refused them, to protect a package that already had a root
index.malloyfrom silently losing query access. That is the decision its closing note called "genuinely open".That population is empty. Checked across everything available locally: 14
malloy-samplespackages (none declare either key, none have anindex.malloy), the three bundled examples (onlygoverned-analyticsdeclaresexplores, explicitly, so the convention never fires), and the one realindex.malloyin a Malloyyo example repo — which is the only model file in that package, so the convention lists it and exposes everything it declares.So the surface is the boundary, always, which is what the design note under Open questions in
docs/malloyyo-dashboards-design.mdproposed in the first place.That reversal is mostly deletion. #988 added ~1,900 lines;
exploresFromConvention,boundaryDeclared(),postLoadWarnings,resolvePatchedExploresOrigin, three warning builders and a 424-line integration spec all existed only because the convention produced a different kind of surface. Here it does not, and none of them appear.The whole feature, stated once: the convention is a default for one manifest field. Nothing downstream records where a surface came from. That single sentence is why the diff to
package.tsis 42 lines rather than 228 —exploresDeclared,exploreSet, both policy pushes,getInvalidExplores,listModels,setPackageMetadataandEnvironment.updatePackageall readpackageMetadata.exploresand cannot tell a filled-in value from a written one.Deprecation, not removal
exploresandqueryableSourcesbehave exactly as before. Both are markeddeprecatedin the OpenAPI spec and earn a load-time warning naming the replacement. Nothing is removed.index.malloydoes not replacequeryableSources: "all", and its warning says so rather than advising a switch."all"is the only way to curate listings without refusing queries, and a derived surface always enforces the boundary. That author is told to keep both keys.Deleting the keys is deliberately out of scope, and not for diff size.
publisher.jsonignores unknown keys, so deleting the parsing would silently turn a curated, bounded package into an uncurated, unbounded one —governed-analytics'sorders_basewould go from refused to freely queryable with no error and no config change. That is a fail-open access change and deserves its own release, where the right behavior is to refuse to load a package still declaring the keys rather than ignore them.examples/governed-analyticsis convertedIt was the only package in the repo declaring a curated surface, and it declared a two-file one — the shape the convention was least obviously able to express. It can, so
publisher.jsonis back to a name and a description. That is the evidenceexplorescan eventually be retired rather than merely deprecated.Two things moved that no unit fixture covered, so a new spec drives the real package and pins both:
import. Malloy marks imported entriesexported: falseand inlines their struct, which is the shape that once denied authorized callers by grafting a gate onto an entry nothing consults (authorize_import_hop.integration.spec.ts, whose header notes no other fixture put a gated source behind an import). This example now does, permanently, so the spec asserts the gate still tracks the caller across three differentTENANTSvalues.index.malloy's surface. The spec pins the full list, because a narrowing there is not a patchable bug — it would mean reverting the conversion.Three warnings had to change, and that is one bug not three
A package curated by its
index.malloyhas noexploreskey, so any message whose fix is "add it toexplores" points at something that does not exist. Three sites said that:index.malloyto a package withdashboards/withholds every dashboard, because a dashboard file is not something anindex.malloycan export);index.malloyexports nothing, so the package looks empty);"explores": [], which is the documented opt-out and was being told to delete itself.All three now name a remedy that exists, from one predicate derived from the tree rather than a stored origin. It only ever picks the wording of a remedy, never behavior.
Verified against a running server, not only in tests
Seven variants of one package served together, plus a gated package, plus a four-hop re-export chain:
index.malloyexporting 2index.malloyexploresomitting the indexorders.malloyexplores: []+ indexqueryableSources: "all"index.malloyreports/index.malloy/compile.get_contexttracks the surface, in enumeration and in ranked search, and everyresource_idit returns round-trips throughexecute_queryverbatim — on a convention package thatmodel_pathis the surface file, which is what keeps agents off a 404.get_contexttracked every publish, no restart.Gate: typecheck clean, 4044 server unit pass, 354 integration pass, skills/manifest/bundle/python green. The 35
config.specfailures reproduce at the base commit and are unrelated.One thing reviewers should push back on if they disagree
export { … }governs what an importing file may see, which is Malloy's rule rather than Publisher's. So "hidden, not out of reach" means reachable through a file that exports it or that exports nothing — not through a file whoseexportomits it, which fails to compile withReference to undefined object. That condition is now in the docs and the new skill. It is the one thing about this convention I would expect to surprise someone mid-migration.🤖 Generated with Claude Code