Conversation
8c55ef5 to
43aef36
Compare
A package whose root holds an index.malloy now gets its discovery surface from that file, with no publisher.json "explores" key. An explicit explores still wins, and when a package has both and they disagree the explicit key is used and the package carries a load-time warning rather than the server guessing. The scaffolder emits an index.malloy so new packages are curated from their first boot. The convention curates discovery only. It deliberately does not enable the query boundary, which is where this departs from the design note in docs/malloyyo-dashboards-design.md. That note also defaults queryableSources to "declared", but "declared" is already the default and the boundary is inert today only because explores is undeclared, so defaulting explores by itself would have switched it on. The boundary denies with a 404 that cannot be told apart from "does not exist", so a package that merely happened to contain a file with that name would have lost query access to every other source, with no config edit and no actionable error. Declaring explores stays the explicit opt-in; adding an index.malloy can hide a source from listings but never makes one unreachable. Two pre-existing backward-compatibility tests in query_boundary.spec.ts fail without that split, which is the evidence it was needed rather than a preference. Package.exploresDeclared is therefore split in two: the listing predicate counts either source, the boundary predicate counts only an author-written explores. The origin flag lives on the Package rather than in packageMetadata, because setPackageMetadata replaces that object wholesale and a name-only PATCH would otherwise promote a convention-derived surface into a declared one and start returning 404s. Verified live on a built bundle: a package with an index.malloy and no explores lists only that file and its exports, and a source hidden by it still answers 200 by name; the same package with an explicit explores that omits the index file lists the declared set, 404s the hidden source, and carries the disagreement warning. Signed-off-by: Monty Lennie <montylennie@gmail.com>
Nine findings from an adversarial review pass. The two that would have
reached a user:
Scaffolding a package named `index` destroyed its own starter model. The
model file is `<source>.malloy`, so for that name it IS index.malloy, and
the surface template was written over it. The result imported itself and
exported a source that no longer existed, so the user's first run produced
a package that could not compile. The surface write is now skipped when the
model already is the index, compared case-insensitively so macOS and Windows
(which would have clobbered it for `Index` too) behave like Linux rather than
only the platforms that would have corrupted it.
A GET-then-PATCH round trip armed the query boundary. `exploresFromConvention`
is deliberately not on the wire, so a client that reads a package, edits its
description and sends the whole object back re-sent the convention's own
`explores` having declared nothing, which flipped the surface to declared and
started 404ing every other source. A body that echoes exactly the current
surface now leaves the origin alone; only a body naming a different surface is
a declaration. The rule is a pure exported helper so it is testable and sits
beside the other explores rules.
The rest:
- A non-array `explores` (the usual typo of the array form) fell through to
the convention silently. It now warns; models no longer vanish unexplained.
- The redundancy warning told authors of a multi-entry `explores` that they
could delete the key for the same surface. That would have dropped every
other entry. It now fires only for a surface of exactly index.malloy.
- The inert-`queryableSources` warning gave `"all"` a remedy that would not
have produced a boundary. Each value now gets advice that works.
- Those warnings survived the PATCH that made them false. setPackageMetadata
drops them when the origin changes.
- setPackageMetadata's origin argument was optional and defaulted to
preserving, so a future caller declaring an explicit surface could have left
the boundary off. Now required; that immediately caught two call sites.
- resolveExplores claimed every package in the wild keeps today's behavior.
Untrue for one already carrying a root index.malloy, which is the intended
feature but not what the comment said. Corrected, and docs gained an
upgrade note.
- One em-dash, which the house rule forbids.
Declined: a PATCH omitting `description` deletes it. Real, on an unchanged
line, and pre-existing, so it belongs in its own change rather than widening
this one.
Both scaffolder e2e tests that asserted `sales.malloy` appears in listings now
assert the surface instead, and a new one proves `export { sales }` carries the
views through, so a scaffolded package is not empty in the UI. The index-name
regression test was mutation-tested.
Gate: typecheck 0, unit 2323/0, integration 250/0, scaffolder 385/0 including
the e2e against a rebuilt bundle.
Signed-off-by: Monty Lennie <montylennie@gmail.com>
Pass 3: an adversarial regression lens on pass 2's own fixes, plus a second security lens. Both independently found the same blocker, which a live run had already turned up: pass 2's round-trip fix was in-memory only. `resolvePatchedExploresOrigin` kept the origin as convention, but the same request still persisted the resolved `explores` to publisher.json. So the boundary stayed off in the running process and armed at the next load. Proven live: the identical query returned 200, then 404 after a reload, with no configuration change in between. That is worse than the bug pass 2 set out to fix, because the arming is decoupled from the request that caused it, and under `--watch-env` the manifest write itself triggers the reload seconds later. A convention-derived surface is now simply never written. The remaining five, all found by the review lenses: - Declaring `explores` equal to the convention surface via the API is indistinguishable from a client echoing back what it read. It is still treated as the echo, because guessing the other way revokes query access, but the package now says so instead of silently ignoring the declaration and leaving the author believing queries are gated. It also names the route that does work. - A non-string entry in the `explores` array was coerced with `String(e)`, which was scope creep in pass 2 and strictly worse than what it replaced: a surface naming "null" matches no model, so curation switched on over an empty set and the package listed nothing, with no warning. A bad element is now treated like a malformed key, which leaves the package uncurated. - A rejected PATCH permanently dropped the convention warnings, because setPackageMetadata's filter is destructive and runs again on the way back. The warnings are snapshotted and restored with the metadata. - Scaffolding `Index` produced only `Index.malloy`, which the server's exact match never treats as the convention, so the package was uncurated while the generated AGENTS.md told the agent that file was its published surface. On Linux that was a regression. A package named `index` in any casing now gets the canonical lowercase filename and the model simply is the surface. - The upgrade note in docs/discovery-and-access.md recommended declaring `explores` to "keep the old behavior". That arms the query boundary, so it is a strictly bigger change than the one it was helping the reader avoid. Renaming the file is the only remedy that restores the previous behavior. Also: one new e2e test from pass 2 asserted a GET by exact path, which the boundary never gates, so it passed either way. It runs a query now. The two fixes nothing pinned (the warning matcher, and the drop on origin change) have tests. Every behavioral fix here was mutation-tested. Gate: typecheck 0, unit 2326/0, integration 254/0, scaffolder 385/0, prettier clean apart from the six standing git-ignored generated files. The new integration spec drives a real server, because the blocker lived in the seam between the in-memory flag, the manifest write, and the next load, which no unit test sees together. Signed-off-by: Monty Lennie <montylennie@gmail.com>
Pass 4: two fresh lenses over the finished change, one reading the code in its
final state and one reading the text as instructions a user or agent will act
on. Between them they found NO defects in the behaviour. Every finding was a
place where the words were wrong about it, which is the class this feature has
now produced in four consecutive rounds.
The through-line: a surface from `index.malloy` and a declared `explores` are
not interchangeable. The first curates listings; the second also enforces the
404 query boundary, because `queryableSources` defaults to `"declared"`. Six
separate pieces of text glossed over that, and each one pointed a reader at a
change to their query surface they had not asked for.
- `api-doc.yaml` still described `explores` as declaration-only, with "when
absent or empty, every model is listed". A client reading the spec would
conclude a response carrying `explores` means the author declared it and the
boundary is in force. Both halves are now stated: the value can be derived,
and the boundary needs a declared one. Same stale premise fixed on
`queryableSources`.
- The docs bullet said models outside the surface "stay queryable" for BOTH
mechanisms. True of the convention, false of `explores`.
- The redundancy warning's escape clause was "keep it if you also rely on
queryableSources". The boundary is on by default, so the author who never
wrote that key is precisely the one who would delete `explores` and silently
reopen every hidden source. Now keyed on the boundary itself, and it says
what deleting costs. The disagreement and malformed warnings gained the same
note, all three worded per `queryableSources` value so none of them claims a
boundary cost where there is no boundary.
- The upgrade note recommended renaming the file as the only way back. There is
a one-key way: `"explores": []` is read as a deliberate "do not curate" and
suppresses the convention exactly. Verified live. Renaming also changes the
model's identity, so `…/models/index.malloy` 404s and siblings importing it
stop compiling, which the note now says.
- The note also missed the likeliest pre-existing shape. An `index.malloy` that
is all imports and no `export { … }` exports nothing, so the package now
serves one model with zero sources and looks empty. Verified live. Called out
with both fixes.
- Three code comments asserted curation requires a manifest key, two of them in
`model.ts`, which is not otherwise in this branch and so had never been
re-read against the feature that invalidated them.
Also: the docs said the disagreement "logs a warning at load". It does not; the
warning rides the package's `warnings` on the REST response and nothing logs it,
so an operator sent to the logs would find nothing.
`docs/malloyyo-dashboards-design.md` still proposes defaulting
`queryableSources` too. It is a design note under Open questions, but it is the
only other in-repo description of the convention, so it now carries a short
note on what shipped and why it differs.
Gate: typecheck 0, unit 2329/0, integration 254/0, scaffolder 385/0,
openapi-spec-validator OK, prettier clean bar the six standing git-ignored
generated files.
Signed-off-by: Monty Lennie <montylennie@gmail.com>
…request Pass 5, three findings, all real. The security one is the pass-3 mitigation defeating itself. The "your declaration was ignored, the boundary is NOT enforced" warning was stored in `manifestWarnings`, which `reloadAllModels` replaces wholesale from a fresh disk parse. Any PATCH carrying `manifestLocation` triggers that reload, and every GET emits `manifestLocation: null`, so a client that round-trips the whole object always sends it. The warning was therefore destroyed before the response was built, for exactly the client the echo rule exists to serve: they got a 200, a body showing `explores` with `queryableSources` of `declared`, no warnings, and nothing gated. Reproduced live before the fix. Warnings raised after load now live in their own array that a reload does not touch, and both are reported. The same mechanism fixes the second half: a PATCH carrying only `queryableSources` at a convention package used to return 200 with no warning at all, because that warning is derived when the manifest is parsed and the next parse had not happened yet. The PATCH response is when the operator is looking, so it is said there too. Third: `exploresPatchIgnoredUnderConvention` told an author who wants the boundary to declare `explores` in publisher.json, which produces nothing if the package also has `queryableSources: "all"`. It was the one warning of the five not branching on that value, and its subject is an author actively trying to arm the boundary, so a wrong remedy there fails in the direction this whole feature guards. Now branched, like the others. Also, a docs correction that understated a cost by a whole package: renaming `index.malloy` was described as making a sibling that imports it "stop compiling". A compile failure aborts the entire package load, so the package disappears from `GET .../packages` and the only trace is `loadErrors` in `/api/v0/status`. Verified live. Gate: typecheck 0, unit 2330/0, integration 256/0, scaffolder 385/0. The postLoadWarnings fix is mutation-tested: reverting it fails three integration tests. Signed-off-by: Monty Lennie <montylennie@gmail.com>
…face The inverse of the defect the previous commit fixed, and just as misleading. `reloadAllModels` sets the origin flag directly rather than through `setPackageMetadata`, so it never cleared `postLoadWarnings`. An author who reads "the query boundary is NOT enforced", does exactly what that warning tells them to do (declare `explores` in publisher.json) and reloads in place ended up with the boundary genuinely ON and a warning still insisting it was off. Found while mutation-testing the previous commit, and the mutation is the reason this is worth recording. My first test for it used `?reload=true`, which runs `Package.create` and returns a fresh object whose post-load warnings are empty regardless, so it passed with the fix reverted: a test that could not fail, pinning nothing. The reachable path is an IN-PLACE reload (a manifest bind, or a PATCH carrying `manifestLocation`), which reloads the same Package. Rewritten against that path, it now fails without the fix. Gate: typecheck 0, unit 2330/0, integration 257/0, scaffolder 385/0. Signed-off-by: Monty Lennie <montylennie@gmail.com>
…rsede them Two findings from a confirmation sweep over the previous commit. Both are the defect class that commit set out to fix, reached through paths it did not cover: the only signal an operator gets about an unenforced boundary was either deleted or contradicted. A rejected PATCH deleted the warning permanently. `setPackageMetadata` runs before validation and its filter is destructive, so a body that flips the origin stripped `postLoadWarnings` on the way in; the rollback then restored only `manifestWarnings`, because that is all the snapshot held. The package was left serving a curated surface with nothing saying its boundary was off. Both arrays are now snapshotted and restored together. Successive PATCHes setting different `queryableSources` values stacked one remedy per value, so a package could carry two contradictory instructions, one of them wrong for the value now in force. That warning now supersedes its own earlier instance rather than accumulating. The test for the first of these is worth a note, because it passed with the fix reverted at first. The rollback hands a previously-computed metadata object back to `setPackageMetadata`, which fossilizes a copy of the old warnings inside it, and `getPackageMetadata` only overwrites that copy when the live list is non-empty. So the deletion is invisible until something else repopulates the list. The test now forces that before asserting, and fails without the fix. Gate: typecheck 0, unit 2330/0, integration 259/0. The scaffolder suite is 383/0 with 2 blocked: its e2e refuses to run while anything else is listening on port 4000, and another worktree's dev server holds it. Both are the `documented URLs` tests, unrelated to this change, and the suite was 385/0 earlier today before that server started. Signed-off-by: Monty Lennie <montylennie@gmail.com>
…Index case
Two findings from pass 8, and both are the same class: a fix that reached one
of two siblings.
The previous commit taught one post-load warning to supersede its own earlier
instance and left the other accumulating. `exploresPatchIgnoredUnderConvention`
returns two strings differing only in their remedy, so exact de-dupe kept both,
and an operator who PATCHed `queryableSources: "declared"` and then `"all"` was
left holding a stale instruction saying that declaring the surface "does
enforce it" when under "all" it does not. Reproduced live by the reviewer, end
to end, including the operator following the stale remedy and still getting an
unenforced boundary. Both warnings now supersede by prefix.
The scaffolder's index-name normalization was pinned by nothing. The existing
test scaffolds `index`, where `${sourceName}.malloy` is already "index.malloy",
so removing the normalization is a no-op and the test passes either way. Only a
capitalized name shows it: without the fix, `Index` puts the model in
Index.malloy and the surface template in index.malloy, which is one path on a
case-insensitive filesystem (one destroys the other) and on a case-sensitive
one leaves a package the server never curates, because its convention test is
an exact match. Now tested, and the test fails without the fix.
Both fixes mutation-tested.
Gate: typecheck 0, unit 2330/0, integration 259/0, scaffolder 386/0. The two
scaffolder e2e cases blocked in the previous commit pass again now that the
other worktree's dev server has released port 4000, confirming that failure was
environmental.
Signed-off-by: Monty Lennie <montylennie@gmail.com>
The brief that specified this feature asked for a log at load naming the file the convention picked, so a discovery surface that nothing in publisher.json mentions is never invisible to an operator working out why a model stopped being listed. Nothing asserted it existed: neutering the whole log call broke no test, so it could have been deleted or silently stopped firing. Found by mutation, using the spyOn(logger, ...) pattern package_worker_path already establishes. It asserts the file it picked AND queryBoundaryEnforced: false, which is the operator's real next question and the one thing a reader of that line most needs. Gate: typecheck 0, unit 2331/0, integration 259/0, scaffolder 386/0. Signed-off-by: Monty Lennie <montylennie@gmail.com>
…ries
The completeness question nobody had asked: this changes served behaviour for
existing packages and had no RELEASE_NOTES entry. That file reserves
`## [Unreleased]` sections for exactly this ("breaking changes, migration
steps"), and the release process copies every one of them into the GitHub
release, so a change with a migration and no section ships silently.
The entry leads with the behaviour change rather than the feature, states the
deliberate departure (the convention curates discovery and never enables the
query boundary, because that denial is a 404 nobody could act on), warns that
`explores` may now be a value the server derived rather than one the author
wrote, and gives the migration: `"explores": []` is the one-key way to keep
the old behaviour, renaming is worse than it sounds, and an all-imports
`index.malloy` serves an empty-looking package.
Two one-line index fixes found in the same sweep: docs/README.md's row for
discovery-and-access.md did not mention the convention, so someone scanning the
index would not find it; and skills/README.md, which is the authority on what
the shared skills may discuss, listed `explores`/`queryableSources` as fair
game but not the convention that malloy-publish/SKILL.md now documents.
Also checked and clean, recorded so it is not redone: no package in examples/
or tests/fixtures has a root index.malloy except the one this branch added, so
the in-repo blast radius is still zero; nothing in the SDK, app or CLI reads
`explores`; and the remaining untouched mentions (api-overview, security-posture,
the lookml-review references, governed-analytics) are all still true as written.
Gate: typecheck 0, unit 2331/0, integration 259/0.
Signed-off-by: Monty Lennie <montylennie@gmail.com>
…listing consumers Two nits from a post-rebase interaction review. Neither is caused by the rebase; both are mine. model.ts hasEmptyDiscoverySurface still said "no curation (no `explores`)". That is the same false claim this branch already corrected twice in the same file, sixty lines above one of them, and my sweep missed it. Curation now means a surface from either source, so the warning DOES fire for a convention-derived one, which is exactly the aggregator shape the release note warns about. A reader reasoning from the old comment would conclude the opposite. The migration text reassured that nothing becomes unreachable because the convention does not gate queries. True, and scoped to query access only. A catalog, a chat surface, MCP getContext or a search indexer reads the listing rather than querying by name, so it does narrow, and a source outside the surface drops out even carrying an #(index) tag. Worth saying plainly one day after #990 shipped a header for the adjacent problem, because that header does not help here: it lifts an identity gate and this is the discovery axis. Declined, and it belongs to its author rather than to this PR: #990 added docs/authorize-bypass-deployment.md without a row in the docs index, so an operator page whose premise is that operators do not know the feature exists is itself unlisted. Flagged for Nathan. Also settled by that review, recorded because it was a real open question: this change does NOT create a sibling of the bug #990 fixed. That one was cross-axis, an identity control silently defeating a discovery opt-in, which is why an identity-axis escape hatch was the right fix. This is same-axis: a per-field tag against a per-package published surface, where the package-level statement is the more general one, and inverting it would let a field annotation defeat curation. Access is untouched either way, pinned by query_boundary.spec.ts:623. Gate: typecheck 0, unit 2365/0, integration 259/0. Signed-off-by: Monty Lennie <montylennie@gmail.com>
An operator review found the change created a question it gave no way to answer, and that the mitigation I documented does not work for the operator it addresses. Post-upgrade there was no way to inventory which packages are curated by convention. On the wire a derived surface is indistinguishable from a declared one, so the only signal was a load-time log line, and a replica up past its log retention could only regenerate the answer by reloading every package and reading stderr. `exploresFromConvention` is now on the package response, read-only and written from the private field last so an inbound value cannot survive. Before upgrading the inventory is still possible without it, and the release note now carries that recipe, because the window closes on upgrade. The mitigation was worse. "Add `"explores": []`" is true but has to land in the package's own publisher.json: a PATCH writes into the server's publisher_data/ copy, so --init, a fresh server root or a replica that re-copies the package all revert it, and "frozenConfig": true refuses the call with 403. For a fleet of packages you do not own there is no operator-side fix at all, and the note said the opposite. It now says so plainly, and warns that a PATCH omitting description deletes it, which matters once bulk-PATCHing is the advertised route. Three more from the same review: rolling back is clean and the note now says so (the server never writes a derived explores and never persists the flag); during a staged rollout a body read from a new replica and PATCHed to an old one persists the surface as declared and arms the boundary fleet-wide, because the old code has no echo detection; and the listing narrowing reaches anything reading listings rather than querying by name, which the previous commit covered. Both new tests mutation-tested: removing the field fails both. Gate: typecheck 0, unit 2365/0, integration 261/0, scaffolder 386/0, openapi-spec-validator OK. Signed-off-by: Monty Lennie <montylennie@gmail.com>
43aef36 to
032d691
Compare
|
Closing this as stale rather than rejected — the idea is sound and the design note behind it stands. The blocker is drift. 23 of this PR's 30 files have been rewritten on The core of it is also a live design decision rather than a mechanical one. Splitting The convention is still described under Open questions in |
|
Revived as #1206, reimplemented against current It also resolves the open decision this PR's closing note called out, in the opposite direction. The convention arms the query boundary rather than curating listings only, because the population that split was protecting turns out to be empty (checked across |
What this does
A package whose root holds an
index.malloynow takes its discovery surface from that file, with noexploreskey inpublisher.json. Listings return that file and whatever it exports; the other models become building blocks. An explicitexploresstill wins, and when a package has both and they disagree, the explicit key is used and the package carries a load-time warning rather than the server guessing.create-malloy-packagenow scaffolds anindex.malloy, so a new package is curated from its first boot.This is the convention described under Open questions in
docs/malloyyo-dashboards-design.md, and it matches what Malloyyo does: its project entry point is the same hardcoded filename, andexport { ... }inside it selects what consumers may query.Where this departs from the design note, and why
The note also defaults
queryableSourcesto"declared". I did not do that, and the reason is that it would not have been additive.queryableSourcesis already"declared"by default. The query boundary is inert today only becauseexploresis undeclared, andPackage.exploresDeclared()was the single predicate driving both the listing curation and the boundary. So defaultingexploreson its own switches the boundary on. That boundary denies with a 404 that is deliberately indistinguishable from "does not exist" (errors.ts, to avoid an enumeration oracle). A package that merely happened to contain a file with that name would have lost query access to every other source by name, with no config edit and no error the author could act on. Malloyyo can be absolute here because every project is greenfield and--developis its escape hatch; Publisher has packages in the wild and no mode split.So the convention curates discovery only.
exploresDeclared()is split in two: the listing predicate counts a surface from either source, and a newboundaryDeclared()counts only anexploresthe author wrote. The rule stays teachable: writing anindex.malloycurates your listings, and declaringexploresis what gates access. Adding anindex.malloycan hide a source from listings; it can never make one unreachable.This is not a preference. Reverting that split fails four tests in
query_boundary.spec.ts, and two of them are pre-existing backward-compatibility tests, one of which is the injection test. That is the evidence the split was required rather than chosen.Happy to change this if you would rather match the note exactly, but it should be a deliberate call, because the failure mode is silent.
Deprecation is accept-with-warning, not removal
Removing
explores/queryableSourceswould be a breaking REST and config change acrossapi-doc.yamland its four generators, for no gain over defaulting. Instead the keys keep working and earn a warning where the convention now covers them, following the precedentresolvePackageScopealready sets in the same file for the deprecated root-levelscope: a pure resolver returning{ value, warnings }, riding the existingmanifestWarningschannel.The warning fires only when a package has an
index.malloyand declares the legacy keys. A blanket warning on everyexploresuser would fire on our ownexamples/governed-analytics, which curates a two-file surface the convention cannot express. That is a warning with no available fix, which is how deprecation warnings get ignored. Three cases, mutually exclusive: the explicit set omits the index file (disagreement), the explicit set names it (now redundant, safe to delete), or the convention is driving and a declaredqueryableSourcesis therefore inert and says so.Implementation notes worth a reviewer's eye
Package's constructor runs both policy pushes, so assigning it afterwards the waymanifestWarningsis assigned would compute the boundary as if the surface were explicit, for exactly the packages this protects.Package, not inpackageMetadata, becausesetPackageMetadatareplaces that object wholesale. A name-only PATCH would otherwise promote a convention-derived surface into a declared one and start returning 404s. The PATCH path preserves it, clears it when the body carries its ownexplores, and restores it on the reject path.index.malloycounts. A nestedreports/index.malloyis an ordinary model; the convention only helps if there is one place to look.explores: []counts as explicit and does not trigger the convention. It reads as a deliberate "do not curate", and today it means everything is listed.Verification
Gate: typecheck clean, unit 2312/0, integration 250/0.
prettier:checkreports the six standing failures, all confirmed git-ignored generated files that CI never sees. Regeneratingskills_bundle.jsonwas needed because the skill text changed.The Malloy re-export syntax in the scaffolded template was compiled through the real worker rather than assumed.
Verified live against a freshly built bundle, not just tests. A package with an
index.malloyand noexploreslists only that file and its export closure, and a source hidden by it still answers 200 when queried by name. The same package with an explicitexploresthat omits the index file lists the declared set, 404s the hidden source, and carries the disagreement warning. A package scaffolded bycreate-malloy-packageand served on a running server listsindex.malloy, exposes its one exported source, and queries successfully.Coordination
This touches no listing path. It edits
applyQueryBoundaryToModelsand the two predicates only, sodiscoverDashboardsand the otherexploreSet()call sites are untouched and the slice 6 work on dashboard curation is unaffected. Worth knowing there:exploreSet()can now return a convention-derived set, so gating dashboards on it will gate them on anindex.malloysurface too, which is almost certainly what you want.