@malloy-publisher/skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/dist/index.d.ts +22 -0
  4. package/dist/index.js +47 -0
  5. package/package.json +54 -0
  6. package/skills/malloy/SKILL.md +45 -0
  7. package/skills/malloy-analysis/SKILL.md +76 -0
  8. package/skills/malloy-analysis-pitfalls/SKILL.md +68 -0
  9. package/skills/malloy-analysis-report/SKILL.md +125 -0
  10. package/skills/malloy-analyze/SKILL.md +262 -0
  11. package/skills/malloy-charts/SKILL.md +417 -0
  12. package/skills/malloy-debug/SKILL.md +121 -0
  13. package/skills/malloy-define/SKILL.md +194 -0
  14. package/skills/malloy-discover/SKILL.md +182 -0
  15. package/skills/malloy-document/SKILL.md +165 -0
  16. package/skills/malloy-getting-started/SKILL.md +57 -0
  17. package/skills/malloy-gotchas-modeling/SKILL.md +277 -0
  18. package/skills/malloy-gotchas-queries/SKILL.md +132 -0
  19. package/skills/malloy-gotchas-rendering/SKILL.md +124 -0
  20. package/skills/malloy-html-data-app-embedding/SKILL.md +39 -0
  21. package/skills/malloy-html-data-app-runtime/SKILL.md +158 -0
  22. package/skills/malloy-html-data-apps/SKILL.md +165 -0
  23. package/skills/malloy-html-data-apps/reference/lazy-load.md +84 -0
  24. package/skills/malloy-html-data-apps/reference/verification-harness.md +121 -0
  25. package/skills/malloy-lookml-review/SKILL.md +79 -0
  26. package/skills/malloy-lookml-review/reference/_concepts.md +42 -0
  27. package/skills/malloy-lookml-review/reference/build-derived-tables.md +110 -0
  28. package/skills/malloy-lookml-review/reference/build-unnest.md +47 -0
  29. package/skills/malloy-lookml-review/reference/curate-visibility.md +66 -0
  30. package/skills/malloy-lookml-review/reference/discover.md +102 -0
  31. package/skills/malloy-lookml-review/reference/document.md +28 -0
  32. package/skills/malloy-lookml-review/reference/propose-fields.md +75 -0
  33. package/skills/malloy-lookml-review/reference/review-coverage.md +102 -0
  34. package/skills/malloy-materialization-tuning/SKILL.md +103 -0
  35. package/skills/malloy-model/SKILL.md +320 -0
  36. package/skills/malloy-model/reference/access-modifiers.md +21 -0
  37. package/skills/malloy-model/reference/analysis-to-model.md +82 -0
  38. package/skills/malloy-model/reference/bridge-tables.md +51 -0
  39. package/skills/malloy-model/reference/normalized-schemas.md +66 -0
  40. package/skills/malloy-model/reference/query-sources.md +57 -0
  41. package/skills/malloy-modeling/SKILL.md +174 -0
  42. package/skills/malloy-notebook-chat/SKILL.md +19 -0
  43. package/skills/malloy-notebooks/SKILL.md +225 -0
  44. package/skills/malloy-patterns/SKILL.md +117 -0
  45. package/skills/malloy-phrase-detection/SKILL.md +75 -0
  46. package/skills/malloy-publish/SKILL.md +111 -0
  47. package/skills/malloy-queries/SKILL.md +270 -0
  48. package/skills/malloy-review/SKILL.md +160 -0
  49. package/skills/malloy-review/reference/output-template.md +164 -0
  50. package/skills/malloy-review/reference/rubric-correctness.md +161 -0
  51. package/skills/malloy-review/reference/rubric-documentation.md +69 -0
  52. package/skills/malloy-review/reference/rubric-governance.md +37 -0
  53. package/skills/malloy-review/reference/rubric-queries.md +78 -0
  54. package/skills/malloy-review/reference/rubric-rendering.md +127 -0
  55. package/skills/malloy-review/reference/rubric-structure.md +68 -0
  56. package/skills/malloy-review/reference/rubric-style.md +59 -0
  57. package/skills/malloy-review/reference/scope-resolution.md +100 -0
  58. package/skills/malloy-review/reference/severity-taxonomy.md +139 -0
  59. package/skills/malloy-scope/SKILL.md +100 -0
@@ -0,0 +1,103 @@
1
+ ---
2
+ name: malloy-materialization-tuning
3
+ description: Optimize a package's Malloy Persistence materializations for cost and performance using the malloy-pub CLI and the materialization history. Recommend what to persist, what to stop persisting, and how to schedule/scope it. Use when the user asks to make a package cheaper or faster, tune persistence, decide what to materialize, or review persist/schedule choices.
4
+ ---
5
+
6
+ # Tuning materializations for cost and performance
7
+
8
+ This skill turns the signals the open-source Publisher already records (the materialization history, per-run timings, and which sources were built vs reused) into concrete, **recommendations-only** advice: which sources to persist, which to stop persisting, and how to schedule and scope them. It is the local counterpart to the platform's usage-driven optimization: the Publisher has the raw signals, and you read them with the `malloy-pub` CLI.
9
+
10
+ > **Recommendations only. Never change a model, schedule, or scope without the user's explicit go-ahead.** Present the findings and the proposed edits, then apply them only when asked. Persisting the wrong source wastes storage and rebuild time; unpersisting a hot one makes queries slow. Let the user decide.
11
+
12
+ Assumes the `malloy-pub` CLI is on PATH and points at the server (`--url` or `MALLOY_PUBLISHER_URL`, default `http://localhost:4000`). Substitute the real environment and package for `<env>` / `<pkg>`.
13
+
14
+ ## Step 1: Take inventory
15
+
16
+ Establish what the package persists today and how it is governed.
17
+
18
+ - **Persist sources:** the sources annotated `#@ persist name="…"` in the package's `.malloy` files. Read the models (or `malloy_getContext` the package) to list them.
19
+ - **Schedule + scope:**
20
+
21
+ ```bash
22
+ malloy-pub schedule view --environment <env> --package <pkg>
23
+ ```
24
+
25
+ This prints the cron (or `none`, meaning publish / on-demand only), the persist **scope** (`package` = artifacts reused across versions; `version` = per published version), and whether a freshness policy is set. A control-plane-managed package (manifestLocation set) is refreshed by the control plane, not the standalone scheduler, so leave its cadence alone.
26
+
27
+ ## Step 2: Read the materialization history
28
+
29
+ The history is where cost lives. Each run records its trigger, timing, and how many sources were built vs reused.
30
+
31
+ - **Across the whole environment** (all packages, newest first; the rows are interleaved and labeled by package, not grouped into contiguous per-package blocks):
32
+
33
+ ```bash
34
+ malloy-pub list materialization --environment <env>
35
+ ```
36
+
37
+ Columns: Package, ID, Status, **Trigger** (`SCHEDULER` vs `ON_DEMAND`), Started, Completed, Error.
38
+
39
+ - **For one package:**
40
+
41
+ ```bash
42
+ malloy-pub list materialization --environment <env> --package <pkg>
43
+ ```
44
+
45
+ - **A single run's detail** (the cost signals):
46
+
47
+ ```bash
48
+ malloy-pub get materialization <id> --environment <env> --package <pkg>
49
+ ```
50
+
51
+ In the JSON, read:
52
+ - `metadata.durationMs`: how long the build took.
53
+ - `metadata.sourcesBuilt` vs `metadata.sourcesReused`: how much work each run actually did. A run that is nearly all _reused_ is cheap; one that is nearly all _built_ every time is where cost accumulates.
54
+ - `metadata.trigger`: `SCHEDULER` (a cron fired it) or `ON_DEMAND`.
55
+ - `manifest.entries[*]`: the persisted sources: `sourceName`, `physicalTableName`, `realization`.
56
+
57
+ Look across several runs, not one: the pattern over the recent history (how often it rebuilds, how much it reuses, how long it takes) is the signal.
58
+
59
+ ## Step 3: Analyze and recommend
60
+
61
+ Weigh rebuild cost against query benefit. Common findings:
62
+
63
+ - **Persist candidate:** an expensive, frequently-queried source that is _not_ persisted (recomputed on every query). Recommend adding `#@ persist name="…"`. Strongest when the source is a heavy aggregate/join reused by many queries and its inputs change slowly.
64
+ - **Removal candidate:** a persisted source that is cheap to compute, rarely queried, or rebuilt far more often than it is read. Recommend dropping the `#@ persist` annotation (and its table): the storage + rebuild cost is not buying anything.
65
+ - **Cadence mismatch:** a `SCHEDULER` cadence out of step with how fast the data changes or how long a build takes. If most scheduled runs are all-reused (nothing changed), the cron is too frequent, so loosen it. If queries routinely read stale data, tighten it. If the build's `durationMs` approaches the interval, the cadence is too aggressive.
66
+ - **Scope mismatch:** `scope: version` re-materializes per published version (right when versions must be isolated, e.g. a schedule); `scope: package` reuses one lineage across versions (cheaper when versions can share). A schedule _requires_ `version`. If a package carries a schedule it does not need, clearing the schedule frees it to use the cheaper package scope.
67
+
68
+ Frame each recommendation with the evidence from Step 2 (the run IDs, timings, built/reused counts) so the user can judge it.
69
+
70
+ ## Step 4: Apply (only once approved)
71
+
72
+ - **Add a persist source:** add the `#@ persist` annotation in the `.malloy` file (use the modeling workflow to validate and reload), then rebuild so it is materialized:
73
+
74
+ ```bash
75
+ malloy-pub materialize --environment <env> --package <pkg> --wait
76
+ ```
77
+
78
+ - **Remove a persist source:** delete the `#@ persist` annotation, then drop the old run's tables **before** rebuilding what remains:
79
+
80
+ ```bash
81
+ malloy-pub delete materialization <id> --environment <env> --package <pkg> --drop-tables
82
+ malloy-pub materialize --environment <env> --package <pkg> --wait
83
+ ```
84
+
85
+ > `--drop-tables` drops **every** physical table in that run's manifest, not just the removed source's: auto-run assigns stable table names and carries unchanged sources forward, so an old run's manifest names tables a newer manifest still serves. Do the drop **first, then rebuild**: `materialize --wait` re-creates every source that is still persisted, ending in the desired state. Dropping a run whose tables the current serving manifest depends on, without an immediate rebuild, breaks queries (`Table ... does not exist`).
86
+
87
+ - **Change the schedule / cadence:**
88
+
89
+ ```bash
90
+ malloy-pub schedule set "0 6 * * *" --environment <env> --package <pkg> # 5-field UTC cron
91
+ malloy-pub schedule clear --environment <env> --package <pkg>
92
+ ```
93
+
94
+ `set` also sets `scope: version` (a schedule requires it); the server rejects an invalid cron or an illegal scope/freshness combination, so a rejection means the change was unsafe.
95
+
96
+ - **Change the scope:** `scope` is declared at the root of the package's `publisher.json` (`"scope": "package"` or `"version"`). Edit it there, then reload/republish the package. Remember a schedule pins scope to `version`, so clear the schedule first if moving to `package`.
97
+
98
+ After applying, re-run Step 2 on the next few builds to confirm the change did what you predicted (more reuse, shorter builds, or a table that is actually read).
99
+
100
+ ## What this skill does not do
101
+
102
+ - It does not decide for the user or apply changes silently; every edit needs an explicit go-ahead.
103
+ - It cannot see per-query read counts (the open-source Publisher records build history, not query-level table usage), so "rarely queried" is a judgment from the model and the user's knowledge, not a measured metric. Say so when it matters.
@@ -0,0 +1,320 @@
1
+ ---
2
+ name: malloy-model
3
+ description: Build Malloy semantic models with base source and joined source files. Use when creating or modifying .malloy files, user asks to "create a malloy model", "add dimensions", "add measures", "create a source", or any Malloy model authoring task.
4
+ ---
5
+
6
+ # Building Malloy Models
7
+
8
+ > **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.
9
+
10
+ ## Getting Started (New Projects)
11
+
12
+ If no `.malloy` files exist yet, do discovery and propose a structure first, then return here to build base source and joined source files. Keep proposals and the analysis behind them in the conversation.
13
+
14
+ **File structure convention** (a flat layout at the package root is the simplest default):
15
+ ```
16
+ <package-name>/
17
+ publisher.json # Required for publishing (name, version, description)
18
+ customers.malloy # Base source: one per table
19
+ products.malloy
20
+ orders.malloy
21
+ user_order_facts.malloy # Computed source
22
+ order_analysis.malloy # Source: one per analytical domain
23
+ customer_health.malloy
24
+ ```
25
+
26
+ Versions for new packages should start at "0.0.1".
27
+
28
+ **Before creating any files**, check for an existing `publisher.json` in the target directory. If one exists for a different package, create a new subdirectory for your package, don't overwrite another package's config.
29
+
30
+ In Publisher an environment is a project, and Publisher is single-tenant, so there is no org/tenant layer to model around: one environment holds one set of packages.
31
+
32
+ ## Prior Art Dispatch
33
+
34
+ If your discovery turned up existing modeling patterns to mirror (a derived table, UNNEST joins, a review or curation pass), read the relevant reference before building.
35
+
36
+ | Pattern found in prior art | Reference to read |
37
+ |---------------------|-------------------|
38
+ | Derived table (PDT/NDT) | `skill:malloy-lookml-review` build-derived-tables guidance |
39
+ | UNNEST joins or struct access | `skill:malloy-lookml-review` build-unnest guidance |
40
+ | Review pass for coverage | `skill:malloy-lookml-review` review-coverage guidance |
41
+ | Curate pass with visibility seeds | `skill:malloy-lookml-review` curate-visibility guidance |
42
+
43
+ ## Base Source Templates
44
+
45
+ ### Base Source (Simple Mode)
46
+
47
+ ```malloy
48
+ source: customers is my_conn.table('sales.customers')
49
+ extend {
50
+ primary_key: customer_id
51
+
52
+ dimension:
53
+ // Use dimension (not rename:) for cleaner column names
54
+ order_type is `Type`
55
+ full_name is concat(first_name, ' ', last_name)
56
+ segment is lifetime_value ?
57
+ pick 'enterprise' when >= 100000
58
+ pick 'mid-market' when >= 10000
59
+ else 'SMB'
60
+
61
+ measure:
62
+ customer_count is count()
63
+ }
64
+ ```
65
+
66
+ > **Every `dimension:` needs `name is expr`.** A bare column name like `dimension: species` is a parse error (e.g. `missing IS at ...`). Raw columns are already usable in `group_by` / `select` without any declaration, so only add a `dimension:` when deriving or renaming a field (e.g. `revenue is price * quantity`).
67
+
68
+ ### Base Source (Curated Mode with Access Modifiers)
69
+
70
+ ```malloy
71
+ ##! experimental.access_modifiers
72
+
73
+ source: orders is my_conn.table('sales.orders')
74
+ include {
75
+ public:
76
+ #(doc) Order identifier
77
+ order_id
78
+
79
+ #(doc) Customer who placed the order
80
+ user_id
81
+
82
+ #(doc) Total sale price in USD
83
+ sale_price
84
+
85
+ #(doc) Order creation timestamp
86
+ created_at
87
+
88
+ internal:
89
+ raw_payload_json // Verified empty via index query + user confirmation
90
+ }
91
+ extend {
92
+ primary_key: order_id
93
+
94
+ dimension:
95
+ #(doc) Date the order was placed
96
+ order_date is created_at::date
97
+
98
+ measure:
99
+ #(doc) Total number of orders
100
+ order_count is count()
101
+
102
+ #(doc) Total revenue in USD
103
+ # currency
104
+ revenue is sum(sale_price)
105
+ }
106
+ ```
107
+
108
+ ### Computed Source (from Query)
109
+
110
+ ```malloy
111
+ import "orders.malloy"
112
+
113
+ source: user_order_facts is from(
114
+ orders -> {
115
+ group_by: customer_id
116
+ aggregate:
117
+ total_orders is count()
118
+ total_revenue is sum(sale_price)
119
+ first_order_date is min(created_at)
120
+ last_order_date is max(created_at)
121
+ }
122
+ ) extend {
123
+ primary_key: customer_id
124
+
125
+ dimension:
126
+ days_since_last_order is days(now - last_order_date)
127
+ is_repeat_buyer is total_orders > 1
128
+
129
+ measure:
130
+ buyer_count is count()
131
+ avg_customer_ltv is avg(total_revenue)
132
+ }
133
+ ```
134
+
135
+ For advanced query-based source patterns (window functions, pipelines), see `reference/query-sources.md`.
136
+
137
+ ## Joined Source File Template
138
+
139
+ ```malloy
140
+ import "customers.malloy"
141
+ import "orders.malloy"
142
+ import "user_order_facts.malloy"
143
+
144
+ #(doc) Customer health analysis. Use for retention, segmentation, and churn risk.
145
+ source: customer_health is customers extend {
146
+ join_one: user_order_facts with customer_id
147
+ join_many: orders on customer_id = orders.customer_id
148
+
149
+ dimension:
150
+ is_at_risk is user_order_facts.days_since_last_order > 90
151
+ and user_order_facts.total_orders > 1
152
+
153
+ measure:
154
+ revenue_per_customer is orders.sale_price.sum() / nullif(customer_count, 0)
155
+ at_risk_count is count() { where: is_at_risk = true }
156
+ }
157
+ ```
158
+
159
+ ## Base vs Joined Sources
160
+
161
+ | | Base Joined Source File | Joined Source File |
162
+ |---|---|---|
163
+ | **Contains** | One table's fields | Joins between base sources |
164
+ | **Dimensions** | Intrinsic to this table only | Cross-source (require joins) |
165
+ | **Measures** | Single-table aggregations | Cross-source aggregations |
166
+ | **Joins** | None (or only lookup joins intrinsic to the source) | Defines relationships between base sources |
167
+ | **Views** | None (views belong in analysis) | None |
168
+ | **One per** | Physical table or computed source | Analytical domain |
169
+
170
+ ## Key Rules
171
+
172
+ - **Every `dimension:` needs `name is expr`**: a bare `dimension: species` is a parse error. Raw columns are queryable directly in `group_by` / `select`; only declare a `dimension:` to derive or rename a field.
173
+ - **Define joined tables before referencing them**, use `import` statements in multi-file architecture
174
+ - **Use `nullif(denominator, 0)` for all division**
175
+ - **Alias joined fields before using in `order_by`**: `group_by: yr is table.year`
176
+ - **Verify join paths** exist before referencing `a.b.field` (each hop needs explicit join)
177
+ - **Pick syntax**: value BEFORE condition, `pick 'Small' when size < 10`
178
+ - **`where:` vs `having:`**: Use `where:` for row filters, `having:` for aggregate filters
179
+ - **Never use `rename:`**, it's incompatible with `include {}`. Always use `internal:` + `dimension:` for cleaner column names (e.g., mark `` `Type` `` as `internal`, add `dimension: order_type is \`Type\``)
180
+ - **Mark raw columns `internal` when a derived dimension replaces them**
181
+ - **Check for duplicate rows** before building measures
182
+ - When both a combined table (all types) and filtered/split tables exist, prefer the split tables
183
+ - **DRY: define measures/dimensions in base source files, not inline in views**
184
+
185
+ ## Parameterizable Filters with `#(filter)`
186
+
187
+ `#(filter)` annotations declare filterable dimensions on a source. Publisher parses them, exposes filter metadata via the API, renders filter widgets in the notebook UI, and **injects `where:` clauses into queries server-side** when callers supply parameters. This gives you a clean way to expose tunable knobs (date range, region, manufacturer) without hand-rolling parameterization in every query.
188
+
189
+ Filters are a **runtime/modeling construct**, not just documentation. They shape governance, query latency (forcing filters keeps result sets bounded), and correctness (see `required` below). They live on the source, never on the consumer: an ad-hoc report or notebook that imports a source inherits and displays that source's filters automatically; it does not (and cannot) declare new ones. If an analysis needs a knob the source doesn't expose, the right move is to add a `#(filter)` to the source itself, not to wedge filtering into the consumer.
190
+
191
+ ### Syntax
192
+
193
+ ```malloy
194
+ #(filter) [name=NAME] dimension=DIMENSION type=TYPE [implicit] [required]
195
+ ```
196
+
197
+ | Parameter | Required | Description |
198
+ |-----------|----------|-------------|
199
+ | `name` | No | Unique identifier for the filter; defaults to the dimension name. Used as the API parameter key. |
200
+ | `dimension` | Yes | The source dimension this filter targets. Quote with `"..."` if the name contains spaces. |
201
+ | `type` | Yes | Comparator (see below). |
202
+ | `implicit` | No | Hides the filter from the UI and API summaries. Used for infrastructure concerns the system injects rather than the user. |
203
+ | `required` | No | Server returns 400 if a required filter has no value at query time. Use this for governance, latency, and correctness, see below. |
204
+
205
+ ### Filter types
206
+
207
+ | Type | Malloy clause | Use case |
208
+ |------|---------------|----------|
209
+ | `equal` | `dimension = 'value'` | Exact match on a single value |
210
+ | `in` | `dimension ? 'a' \| 'b' \| 'c'` | Match any of multiple values |
211
+ | `like` | `dimension ~ '%value%'` | Substring / pattern matching |
212
+ | `greater_than` | `dimension > value` | Range floor (after, minimum) |
213
+ | `less_than` | `dimension < value` | Range ceiling (before, maximum) |
214
+
215
+ ### Example
216
+
217
+ ```malloy
218
+ #(filter) name=Manufacturer dimension=Manufacturer type=in
219
+ #(filter) name=Subject dimension=Subject type=like
220
+ #(filter) name=Major_Recall dimension="Major Recall" type=equal
221
+ #(filter) name=Recall_After dimension="Report Received Date" type=greater_than
222
+ #(filter) name=Recall_Before dimension="Report Received Date" type=less_than
223
+ source: recalls is duckdb.table('data/auto_recalls.csv') extend {
224
+ measure:
225
+ recall_count is count()
226
+ }
227
+ ```
228
+
229
+ For date-range filters, declare two filters with distinct `name` values targeting the same dimension (one `greater_than`, one `less_than`).
230
+
231
+ ### When to use `required`
232
+
233
+ `required` filters are a correctness, latency, and governance mechanism, not just UX. Mark a filter `required` when:
234
+
235
+ 1. **Modeling correctness, the source's `primary_key:` is only unique under a filter.** If a high-cardinality key is not unique across the whole table but is unique within a scoping dimension, then that scoping dimension MUST be supplied for symmetric aggregation to produce correct numbers. For example, if `events.id` repeats across days but is unique within a single `event_date`, queries that don't pin the date can fan out and return hash-collision-sized garbage (~10²¹). Declare `#(filter) name=Event_Date dimension=event_date type=equal required` so the server refuses queries that don't provide it.
236
+ 2. **Query latency, the source spans more data than any single query should scan.** A multi-year, multi-region table where every reasonable analysis is scoped to a date range or region: making the date filter required prevents accidental full-table scans.
237
+ 3. **Partial views** that are only meaningful inside a date range, region, or business segment.
238
+ 4. **Governance**, an analyst should never query the raw source without a scoping filter applied.
239
+
240
+ For (1), pair the required filter with a comment explaining the cardinality dependency, and consider also declaring `#(doc)` on the source noting the constraint.
241
+
242
+ ### When to use `implicit`
243
+
244
+ Use `implicit` for filters the *system* must inject but users should not see. The filter applies; it just doesn't appear in the UI or API filter list.
245
+
246
+ ### Type-aware literals
247
+
248
+ Publisher formats values based on the dimension's data type, `string` → `'value'`, `boolean` → bare `true`/`false`, `date` → `@YYYY-MM-DD`. You don't quote values yourself in the API call; Publisher handles formatting.
249
+
250
+ ### Bypass
251
+
252
+ Pass `bypass_filters=true` (REST) or `bypassFilters: true` (POST body) to skip filter injection entirely. Use sparingly, required-filter governance only works if bypass is restricted to trusted callers.
253
+
254
+ ## Access Control: Source Gating with `#(authorize)`
255
+
256
+ Gate query access to a source with `#(authorize)` over declared `given:` values (`given:` is Malloy's native runtime-parameter mechanism, the going-forward replacement for `#(filter)`). Publisher evaluates a source's in-scope `#(authorize)` expressions against the request's supplied givens before running the query; if **any** expression returns `true` the request proceeds, otherwise it is denied with **403**. A source with no in-scope `#(authorize)` annotations is unrestricted.
257
+
258
+ ```malloy
259
+ ##! experimental.givens
260
+
261
+ given:
262
+ ROLE :: string
263
+
264
+ #(authorize) "$ROLE = 'analyst'"
265
+ source: orders is duckdb.table('orders.parquet') extend {
266
+ measure: order_count is count()
267
+ }
268
+ ```
269
+
270
+ - **Source-level** `#(authorize) "<expr>"` gates that one source. **File-level** `##(authorize) "<expr>"` applies to every source in the file. Multiple gates combine as an OR, access is granted if any one is true, so a permissive file-level gate is a **model-wide override**, not an added restriction.
271
+ - **Not inherited, not joined.** The gate applies only to the source a query directly runs against. A source that `extend`s a locked base does **not** inherit the base's gate, and a gate on a source reached only via `join_*` never fires. Pair a locked base (`#(authorize) "false"`) with curated extension sources, using access modifiers (`include { public: …, private: * }`), so an extension re-exposes only a curated column surface instead of leaking the base's data through the join or extend.
272
+ - The expression may reference only givens and literals, never a column of the gated source; the check runs against a synthetic probe row, not your data.
273
+
274
+ > **Trust caveat.** Givens are **caller-asserted**, anyone who can reach the query API can claim a favorable given, e.g. `{"ROLE":"admin"}`. `#(authorize)` is only a real boundary when it sits behind a trusted tier that sets givens from its own verified context, never directly from an untrusted caller. It is not, on its own, end-user authentication.
275
+ >
276
+ > **Forward direction.** Givens are how access control is built here, and the planned next step is **identity-bound ("secure") givens** - reserved values a trusted tier populates from a verified token or proxy header, which the caller cannot override - turning `#(authorize)` into a standalone boundary. Model access on `given:` + `#(authorize)` now; it is the surface that carries forward.
277
+
278
+ Full syntax, OR/override semantics, validation, and the error contract are covered in your deployment's `#(authorize)` reference documentation.
279
+
280
+ ## Join Syntax
281
+
282
+ - Simple join: `join_one: users with user_id`
283
+ - Expression join: `join_one: origin is airports on origin_code = origin.code`
284
+ - Composite key: `join_one: items on order_id = items.order_id and product_id = items.product_id`
285
+ - Multiple joins to same table: `join_one: origin_airport is airports with origin`
286
+
287
+ **Join Types:** `join_one:` (many-to-one, efficient) | `join_many:` (one-to-many, always safe) | `join_cross:` (many-to-many)
288
+
289
+ **Verify cardinality** before writing joins: `run: target -> { group_by: fk_col, aggregate: n is count(), having: n > 1, limit: 5 }`. 0 results → `join_one`. Any results → `join_many`.
290
+
291
+ ## After Writing: Check & Review
292
+
293
+ Check diagnostics after writing. Errors cascade, fix the FIRST error only, then re-check. If errors persist, use the debugging strategy: look at first error, search docs if unsure, fix, repeat.
294
+
295
+ **Validate with `execute_query`:** Run queries, check distributions, verify measures, confirm joins (no fan-out).
296
+
297
+ To inspect the sources and fields a model already defines, ground yourself with `get_context`. It returns the package's sources, views, and fields, so there is no separate schema-search step. When you're unsure of Malloy syntax, call `search_malloy_docs` rather than guessing.
298
+
299
+ ## Advanced Patterns
300
+
301
+ Load the relevant reference file when you encounter these scenarios:
302
+
303
+ | Scenario | Read |
304
+ |----------|------|
305
+ | Need pre-aggregated or windowed source | `reference/query-sources.md` |
306
+ | Curating access modifiers | `reference/access-modifiers.md` |
307
+ | Normalized/ER-style schema (4+ tables, no clear fact table) | `reference/normalized-schemas.md` |
308
+ | Formalizing analysis into a model | `reference/analysis-to-model.md` |
309
+ | Many-to-many / bridge tables / composite keys | `reference/bridge-tables.md` |
310
+
311
+ ## Done
312
+
313
+ Step complete. Output: base source files (`.malloy`, one per table) and joined source files (`.malloy`, one per analytical domain).
314
+
315
+ **Suggest next steps to the user:**
316
+
317
+ - Open the model in the browser to see it live: `http://localhost:4000/<environmentName>/<packageName>` for the package, or `http://localhost:4000/<environmentName>/<packageName>/<modelPath>` for a single model file. First confirm the running server actually serves this package (it is in the loaded `publisher.config.json`, or mounted live with `--server_root . --watch-env <env>`); a package the server has not loaded returns a 404, so do not hand over a link to a package that was just authored but never loaded.
318
+ - Build a notebook with interactive filters over the model (see `skill:malloy-notebooks`).
319
+ - Run analysis questions against the model (see `skill:malloy-analysis`).
320
+ - When you're ready to serve the model, publishing is out of scope for open-source Publisher v1: self-hosters commit the package to git and use their host's publish path.
@@ -0,0 +1,21 @@
1
+ # Access Modifiers: Curate the Interface
2
+
3
+ Add `##! experimental.access_modifiers` at the top of base source files.
4
+
5
+ **Every column must be explicitly declared** in `include {}`, listed under a single `public:` block with `#(doc)` tags, or under `internal:`. Do not use `public: *`.
6
+
7
+ **`internal` fields are NOT accessible through join paths.** Most fields should stay public.
8
+
9
+ **GOTCHA:** `include { except: a, b }` without a `public:` block makes remaining fields **private**, not public.
10
+
11
+ **What to mark `internal` (verify via index query + confirm with user first):**
12
+ - Empty/garbage columns
13
+ - Raw JSON/array blobs (when all useful fields extracted as dimensions)
14
+ - Duplicate columns where only one is correct
15
+ - Raw columns replaced by a derived dimension
16
+
17
+ **What to mark `private` (only after user confirms, very rare):** Highly sensitive data only (SSNs, raw credit cards, passwords).
18
+
19
+ **Include positioning:**
20
+ - `include { } extend { }`: New definitions in extend are PUBLIC
21
+ - `extend { } include { }`: Include covers everything
@@ -0,0 +1,82 @@
1
+ # Starting from Analysis
2
+
3
+ When you've been doing analysis, writing queries, building views, creating notebooks, and want to formalize the work into a reusable semantic model, follow this process.
4
+
5
+ **The model emerges from your analysis.** You don't design sources top-down, you identify what's worth encoding as reusable building blocks.
6
+
7
+ ## Step 1: IDENTIFY, what belongs in a model?
8
+
9
+ Scan your analysis `.malloy` and `.malloynb` files. For each dimension, measure, join, or calculation, ask: **is this a reusable building block?**
10
+
11
+ A measure or dimension belongs in a model if it meets ANY of these criteria:
12
+
13
+ | Criteria | Example |
14
+ |----------|---------|
15
+ | **Reusable**, another query or user would benefit from this definition | `revenue is sum(sale_price)`, any analysis involving orders needs this |
16
+ | **Composable**, can be combined with other concepts for future analysis | `customer_tier` dimension, useful for segmenting any metric |
17
+ | **Encodes business logic**, standardizes a calculation that should be consistent | Regex that parses a messy column, status mapping, tier boundaries |
18
+ | **Enables reproducibility**, someone repeating this analysis shouldn't re-derive it | Complex window function for first-touch attribution |
19
+
20
+ **Include even if used once**, if a calculation encodes business logic (regex, tier boundaries, status mappings) or was hard to get right (window functions, multi-step derivations), it belongs in the model.
21
+
22
+ **When uncertain, propose with reasoning and ask the user.** Show what you think should be modeled and why. The user confirms, adjusts, or defers.
23
+
24
+ ### What to scan for
25
+
26
+ | Pattern in analysis | Promotes to |
27
+ |---------------------|-------------|
28
+ | Tables used in queries | Base source candidates |
29
+ | Dimensions that define segments, categories, or time buckets | Dimensions in base sources |
30
+ | Measures that compute business metrics | Measures in base sources |
31
+ | Joins between tables | Joined source candidates |
32
+ | Pre-aggregated patterns at different grains | Computed source candidates |
33
+ | Complex calculations (regex, window functions) | Dimensions in base sources, especially worth modeling |
34
+ | Repeated `where:` filters that define business segments | Filtered measures or dimensions |
35
+
36
+ ## Step 2: DECOMPOSE, break into composable building blocks
37
+
38
+ Take the identified elements and decompose compound analysis into atomic, reusable parts.
39
+
40
+ **Atomic measures:** Break compound insights into their building blocks.
41
+ ```
42
+ Analysis: "Revenue grew 23% YoY for enterprise customers"
43
+ → Measure: revenue is sum(sale_price)
44
+ → Measure: enterprise_revenue is sum(sale_price) { where: segment = 'enterprise' }
45
+ → Dimension: segment (already exists or needs creation)
46
+ ```
47
+
48
+ **Reusable dimensions:** Separate bucketing logic from one-off filters.
49
+ ```
50
+ Analysis: "High-value users (>$500 LTV) churn less"
51
+ → Dimension: ltv_tier is lifetime_value ? pick 'high' when > 500 else 'standard'
52
+ → Not: a filtered measure that bakes in the $500 threshold
53
+ ```
54
+
55
+ **What to leave behind:**
56
+ - Ad-hoc calculations that answered a specific question but aren't reusable
57
+ - Filtered aggregates tied to a specific finding (unless the filter defines a business segment)
58
+ - Views that are specific to one analysis narrative
59
+
60
+ ## Step 3: STRUCTURE, build the model files
61
+
62
+ Map the identified and decomposed elements to the file architecture:
63
+ - Single-table measures/dimensions → base source files (one per table)
64
+ - Cross-table measures/joins → joined source files (one per analytical domain)
65
+ - Pre-aggregated patterns at different grains → computed source files (see `reference/query-sources.md`)
66
+
67
+ **The analysis files remain as-is**, they're the record of your investigation. The model is the reusable layer extracted from them. Later, analysis files can be refactored to `import` the model and reference its sources instead of defining everything inline.
68
+
69
+ ## Step 4: VALIDATE, confirm the extraction
70
+
71
+ Run key analysis queries against the new model to confirm results match. If numbers differ, a dimension or measure was extracted incorrectly.
72
+
73
+ Then apply the same curate and document standards as the rest of this skill (access modifiers, `#(doc)` tags).
74
+
75
+ ## When to formalize
76
+
77
+ Don't formalize too early. Good signals:
78
+ - You've answered 3+ distinct questions and keep redefining the same measures
79
+ - You find yourself copy-pasting dimensions or joins between queries
80
+ - Someone else needs to use your analysis patterns
81
+ - You want a notebook or dashboard that should survive data refreshes
82
+ - You built something non-trivial (regex, window function, multi-step logic) that would be painful to recreate
@@ -0,0 +1,51 @@
1
+ # Bridge Tables & Composite Keys
2
+
3
+ ## Bridge Table Pattern (Many-to-Many)
4
+
5
+ Bridge table gets `join_one` to each source; other sources get `join_many` to bridge:
6
+
7
+ ```malloy
8
+ // Bridge source
9
+ source: enrollments is conn.table('enrollments') extend {
10
+ join_one: students with student_id // each enrollment → one student
11
+ join_one: courses with course_id // each enrollment → one course
12
+ }
13
+ // Query source: students with their enrollments
14
+ source: student_courses is students extend {
15
+ join_many: enrollments on student_id = enrollments.student_id
16
+ }
17
+ ```
18
+
19
+ ## Composite Key Joins
20
+
21
+ When no single column is unique (bridge tables, time-series snapshots), use `on` with `and`:
22
+
23
+ ```malloy
24
+ // `with` is single-column only, composite keys MUST use `on` + `and`
25
+ join_one: items on order_id = items.order_id and product_id = items.product_id
26
+ ```
27
+
28
+ `primary_key` is single-column only, pick the highest-cardinality column.
29
+
30
+ ## Cardinality Verification
31
+
32
+ Before writing any join, check FK uniqueness with `execute_query`:
33
+
34
+ ```malloy
35
+ run: target_table -> { group_by: fk_col, aggregate: n is count(), having: n > 1, limit: 5 }
36
+ ```
37
+
38
+ - 0 results = unique → `join_one` (more efficient)
39
+ - Any results = not unique → `join_many` (always safe)
40
+
41
+ For composite keys, test multi-column: `group_by: col_a, col_b`, same pattern.
42
+
43
+ ## Post-Join Verification
44
+
45
+ After writing joins, verify row counts haven't inflated:
46
+
47
+ ```malloy
48
+ run: source -> { aggregate: row_count is count() }
49
+ ```
50
+
51
+ If higher than the raw table, a `join_one` target key isn't unique, switch to `join_many`.
@@ -0,0 +1,66 @@
1
+ # Normalized Schemas (3-Stage Pattern)
2
+
3
+ Use this pattern when data is in normalized/relational form, ER-diagram style schemas, application databases (CRM, ERP, OLTP), many tables linked by foreign keys, no clear single fact table, or many-to-many relationships.
4
+
5
+ For most star/snowflake schemas, the base source + joined source layers are sufficient.
6
+
7
+ **Why multiple sources?** Different sources answer different questions.
8
+ - `deals` with `join_one: company` → counts only companies WITH deals
9
+ - `companies` with `join_many: deals` → counts ALL companies
10
+
11
+ ## The 3 Stages
12
+
13
+ | Stage | Purpose | Naming |
14
+ |-------|---------|--------|
15
+ | 1. Base | Raw tables + single-table dimensions | `_xxx_base` |
16
+ | 2. Modeled | Forward joins (each relationship defined once) | `_xxx_modeled` |
17
+ | 3. Query Sources | Reverse joins + measures (public) | `xxx` (no prefix) |
18
+
19
+ ## Example
20
+
21
+ ```malloy
22
+ // Stage 1: Base sources
23
+ source: _companies_base is duckdb.table('company') extend {
24
+ primary_key: _id
25
+ dimension:
26
+ company_name is name
27
+ size_bucket is employee_count ?
28
+ pick 'Small' when < 50 pick 'Medium' when < 500 else 'Large'
29
+ }
30
+ source: _deals_base is duckdb.table('deal') extend { primary_key: _id }
31
+
32
+ // Stage 2: Modeled (forward joins, defined once)
33
+ source: _companies_modeled is _companies_base extend {}
34
+ source: _deals_modeled is _deals_base extend {
35
+ join_one: company is _companies_modeled on company_id = company._id
36
+ }
37
+
38
+ // Stage 3: Query sources (reverse joins + measures)
39
+ source: companies is _companies_modeled extend {
40
+ join_many: deals is _deals_modeled on _id = deals.company_id
41
+ measure:
42
+ company_count is count()
43
+ deal_count is count(deals._id)
44
+ }
45
+ source: deals is _deals_modeled extend {
46
+ measure:
47
+ deal_count is count()
48
+ total_value is sum(amount)
49
+ }
50
+ ```
51
+
52
+ ## CRITICAL: Reuse Modeled Sources
53
+
54
+ In query sources, join to MODELED sources, not raw tables:
55
+
56
+ ```malloy
57
+ // WRONG - joins raw table, loses dimensions/measures
58
+ source: accounts is _accounts extend {
59
+ join_many: trans is conn.table('financial.trans') on ...
60
+ }
61
+
62
+ // RIGHT - joins the modeled source
63
+ source: accounts is _accounts extend {
64
+ join_many: trans is transactions on account_id = trans.account_id
65
+ }
66
+ ```