@malloy-publisher/skills 0.1.47 → 0.1.49

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 (49) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +1 -1
  3. package/skills/eval-answer/scripts/init_truth_package.py +1 -1
  4. package/skills/eval-answer/scripts/verify_goldens.py +1 -1
  5. package/skills/eval-diagnose/SKILL.md +1 -1
  6. package/skills/eval-diagnose/scripts/diagnose.py +1 -1
  7. package/skills/eval-import/SKILL.md +1 -1
  8. package/skills/eval-improve/SKILL.md +1 -1
  9. package/skills/eval-judge/SKILL.md +1 -1
  10. package/skills/eval-loop/SKILL.md +1 -1
  11. package/skills/eval-loop/reference/running-a-run.md +1 -1
  12. package/skills/eval-loop/scripts/run_baseline.py +2 -2
  13. package/skills/eval-loop/scripts/serve.py +1 -1
  14. package/skills/eval-report/SKILL.md +1 -1
  15. package/skills/malloy-analysis/SKILL.md +3 -2
  16. package/skills/malloy-analysis-report/SKILL.md +1 -1
  17. package/skills/malloy-charts/SKILL.md +1 -1
  18. package/skills/malloy-classify/SKILL.md +195 -0
  19. package/skills/malloy-compliance-review/SKILL.md +123 -0
  20. package/skills/malloy-compliance-review/reference/checks.md +50 -0
  21. package/skills/malloy-compliance-review/reference/register.md +74 -0
  22. package/skills/malloy-compliance-review/reference/report.md +48 -0
  23. package/skills/malloy-compliance-review/scripts/compliance.py +924 -0
  24. package/skills/malloy-compliance-review/scripts/compliance_test.py +375 -0
  25. package/skills/malloy-dashboards/SKILL.md +1 -1
  26. package/skills/malloy-define/SKILL.md +1 -1
  27. package/skills/malloy-define-agent/SKILL.md +109 -0
  28. package/skills/malloy-discover/SKILL.md +4 -2
  29. package/skills/malloy-document/SKILL.md +43 -12
  30. package/skills/malloy-getting-started/SKILL.md +5 -4
  31. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  32. package/skills/malloy-html-data-apps/SKILL.md +2 -2
  33. package/skills/malloy-html-data-apps/reference/runtime.md +49 -30
  34. package/skills/malloy-lookml-review/SKILL.md +1 -1
  35. package/skills/malloy-materialization/SKILL.md +1 -1
  36. package/skills/malloy-model/SKILL.md +59 -44
  37. package/skills/malloy-model-as-you-go/SKILL.md +1 -1
  38. package/skills/malloy-modeling/SKILL.md +1 -1
  39. package/skills/malloy-notebooks/SKILL.md +1 -1
  40. package/skills/malloy-phrase-detection/SKILL.md +24 -10
  41. package/skills/malloy-powerbi-review/SKILL.md +3 -3
  42. package/skills/malloy-powerbi-review/reference/limitations.md +2 -2
  43. package/skills/malloy-powerbi-review/reference/translate-measures.md +1 -1
  44. package/skills/malloy-publish/SKILL.md +1 -1
  45. package/skills/malloy-queries/SKILL.md +1 -1
  46. package/skills/malloy-reports/SKILL.md +1 -1
  47. package/skills/malloy-review/SKILL.md +1 -1
  48. package/skills/malloy-run-agent/SKILL.md +14 -3
  49. package/skills/malloy-source-unreachable/SKILL.md +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/skills",
3
3
  "description": "Agent skills for Malloy and the Malloy Publisher",
4
- "version": "0.1.47",
4
+ "version": "0.1.49",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-answer
3
3
  description: 'Score one analytical answer against a verified golden, and score which of the entities the golden depends on retrieval delivered to the answerer. Run the contamination checklist, re-execute the submitted query yourself, then spawn a judge subagent per skill:eval-judge. Append attempt, tool_call, score, and retrieval_score events to the file ledger (reference/ledger-schema.md). Never explain the failure (eval-diagnose) or edit the model (eval-improve). Use when asked whether an answer was correct, to score a run, or to baseline a model.'
4
- version: 0.1.47
4
+ version: 0.1.49
5
5
  ---
6
6
 
7
7
  # Evaluate One Answer
@@ -15,7 +15,7 @@ The doctrine mandates raw-table goldens: every golden is computed in a
15
15
  semantics-free package over the same raw tables, never through the model under
16
16
  test, so a model bug cannot certify its own golden. `set.json` has had a
17
17
  `truthPackage` field for that since the schema was written -- and nothing built
18
- one. The VideoAmp author wrote `truth.malloy`, a `publisher.json`, a second
18
+ one. One set's author wrote `truth.malloy`, a `publisher.json`, a second
19
19
  server config and an MCP client by hand before authoring case one: an hour of
20
20
  plumbing for a structure that is entirely derivable from the package.
21
21
 
@@ -25,7 +25,7 @@ WHAT IT CHECKS, AND WHAT EACH CATCHES
25
25
 
26
26
  2. rubric numbers -- every figure the rubric's ACCEPTING clause quotes appears
27
27
  in the golden rows or their column sums. Would have caught the
28
- authoring defect that cost an arm on the VideoAmp set: a rubric
28
+ authoring defect that cost an arm on a warehouse-backed set: a rubric
29
29
  that said "122,935,709.72 (group 31681)" while its own executed
30
30
  rows held a different number, so the judge -- correctly --
31
31
  reported the golden as suspect. Reported as REVIEW items, not
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-diagnose
3
3
  description: 'Diagnose why a scored answer failed and who owns the fix, then cluster the failures by shared root cause. Walk dataset, agent-call, get_context/model, get_context/retrieval, construction, then model-definition. Append issue events to the file ledger, linked by traceId, one per cluster. Use after eval-answer, when triaging a run, or before changing a model. Does not edit the model (eval-improve).'
4
- version: 0.1.47
4
+ version: 0.1.49
5
5
  ---
6
6
 
7
7
  # Diagnose One Answer
@@ -1032,7 +1032,7 @@ def main(argv: list[str] | None = None) -> int:
1032
1032
  # The agent's clusters are the clusters of record, in the file the package
1033
1033
  # builder reads. Until 2026-09-02 only cluster_failures.py wrote this file,
1034
1034
  # so the browsable package always carried the mechanical grouping -- which
1035
- # on both the ecommerce and VideoAmp runs charged everything to retrieval
1035
+ # on both the ecommerce and warehouse-backed runs charged everything to retrieval
1036
1036
  # while the diagnosis beside it said otherwise.
1037
1037
  # Imported, not spelled: this file and `cluster_failures.py` both write
1038
1038
  # `clusters.jsonl`, and when `score_retrieval` renamed the labels these two
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-import
3
3
  description: 'Turn a list of questions into an eval set, whatever shape it arrived in: a JSONL a customer sent, a CSV, a spreadsheet export, a markdown doc, an email thread, or a pull from production logs. Classify each item by what came WITH the question (a query, a number, prose criteria, or nothing), write cases.jsonl and set.json per reference/case-format.md, keep the file as it arrived, and seal each question so a later edit is detectable. Never marks a value-bearing golden verified and never invents a question. Use when questions arrive from outside and need to become a set, or before the first run against a set nobody here authored.'
4
- version: 0.1.47
4
+ version: 0.1.49
5
5
  ---
6
6
 
7
7
  # Import questions into an eval set
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-improve
3
3
  description: 'Make the smallest safe edit that closes a diagnosed gap the model repo owns: a Malloy model edit, or a package skill under skills/. Every factual claim carries a probe receipt. Use after eval-diagnose, or when asked to fix a model so an agent can discover the right answer. Never accepts its own edit; the acceptance check belongs to eval-loop. Does not decide whether an answer was wrong (eval-answer) or why (eval-diagnose).'
4
- version: 0.1.47
4
+ version: 0.1.49
5
5
  ---
6
6
 
7
7
  # Improve the Model
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-judge
3
3
  description: 'Decide whether ONE answer matches its golden, and say whether you believe the golden. Read this before emitting any verdict. Covers containment, column pairing, near_match, refusals, and the gold_status judgement. Use when scoring an attempt in an evaluation run; never to conduct a run (eval-loop), diagnose a failure (eval-diagnose) or edit a model (eval-improve).'
4
- version: 0.1.47
4
+ version: 0.1.49
5
5
  ---
6
6
 
7
7
  # The judge
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-loop
3
3
  description: 'Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint. You are the conductor: import cases into the file ledger, spawn a blind answerer, then run eval-answer, eval-diagnose, and eval-improve. Persistence is plain files: the set in the model package''s evals/ directory, runs in the set''s workdir; checkpoints are git commits of the model repo. `eval.py` runs each step from the set''s eval.toml. Use to score a model, diagnose failures, improve behind an acceptance check, or roll back a bad direction.'
4
- version: 0.1.47
4
+ version: 0.1.49
5
5
  ---
6
6
 
7
7
  # The Evaluation Loop
@@ -155,7 +155,7 @@ Three layers, in this order, and the order is what makes it readable:
155
155
  Order of magnitude for planning, **calibrated on ecommerce over local duckdb**:
156
156
  a Sonnet arm over a few dozen cases costs single-digit dollars and finishes in
157
157
  minutes, at roughly a dime and a handful of turns per case. A proxied warehouse
158
- is a different regime: the VideoAmp set ran at $0.33 per case on Sonnet and
158
+ is a different regime: a proxied-warehouse set ran at $0.33 per case on Sonnet and
159
159
  $0.57–0.71 on Opus, ~100 s per case, driven by warehouse latency and query
160
160
  errors -- budget 4x when the data is not local. Budget **five** such arms for a
161
161
  defensible claim -- a baseline, two for the A/A, and two post-edit -- plus the
@@ -827,7 +827,7 @@ def server_alive(a: argparse.Namespace) -> bool:
827
827
 
828
828
  A dead Publisher returns an empty body that the answerer reads as a bad
829
829
  answer and the judge scores as a miss (Publisher's own friction log, item
830
- 7; the VideoAmp run's server died mid-session and a raw probe caught it,
830
+ 7; a past run's server died mid-session and a raw probe caught it,
831
831
  not the harness). So every attempt asks first. Local: the REST root.
832
832
  Platform: any HTTP answer from the MCP URL -- it says 405 to a bare GET,
833
833
  which is fine; refusal or timeout is not.
@@ -3760,7 +3760,7 @@ def main(argv: list[str] | None = None) -> int:
3760
3760
 
3761
3761
  # Lint the set's expected entities before a dollar is spent: an id the
3762
3762
  # model does not declare is a stale set, not a retrieval miss, and both
3763
- # VideoAmp platform runs carried five of them (the set was written against
3763
+ # platform runs of one set carried five of them (the set was written against
3764
3764
  # a later package) which read as misses until someone checked by hand.
3765
3765
  declared = None
3766
3766
  if a.target != "platform" and a.publisher:
@@ -24,7 +24,7 @@ WHY THIS EXISTS
24
24
 
25
25
  The doctrine has always said "serve the model persistently, not `&` from a
26
26
  shell that exits", and gave no recipe. A server launched from a shell wrapper
27
- is SIGTERM'd with the wrapper's session when it exits; both VideoAmp servers
27
+ is SIGTERM'd with the wrapper's session when it exits; both servers of a past run
28
28
  were lost that way once, and the answerer read the dead server's empty body as
29
29
  a bad answer. This starts the server in its OWN session (setsid semantics via
30
30
  start_new_session), redirects its output to a log in the server root, writes a
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-report
3
3
  description: 'Write the summary a person reads after an evaluation run: what ran, what it scored, which failures were the MODEL and which were the EVAL itself (out of turns, contaminated, unestablished golden, unreadable judge), what was learned, and what to do next. Every claim links to the artifact behind it. Use at the end of any run, before quoting a number to anyone. Never scores an answer (eval-answer) or decides who owns a failure (eval-diagnose).'
4
- version: 0.1.47
4
+ version: 0.1.49
5
5
  ---
6
6
 
7
7
  # Report a Run
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-analysis
3
3
  description: "Read before your first get_context call on any business or data question. The required workflow, not background: which source, which rows, how to verify. Skipping it causes confident wrong answers."
4
- version: 0.1.47
4
+ version: 0.1.49
5
5
  ---
6
6
 
7
7
  <!-- Copyright (c) Credible Data Inc. SPDX-License-Identifier: MIT -->
@@ -23,7 +23,8 @@ Restate what is being asked: which metric, which breakdown (group-by), which fil
23
23
  Find the right entities before writing any query.
24
24
 
25
25
  - If you do not already know which package to work in, confirm the environment and package with the user before continuing.
26
- - **Load `skill:malloy-phrase-detection`**, then call `get_context` with entity targets that describe the fields the question needs: a `measure` for the metric, a `dimension` for each breakdown or filter, and a `view` if the question sounds like a canned report. **Every target carries a `search_text`**: a target without one enumerates a capped catalog instead of searching, and the cap is blind to your question. The tool description covers what comes back and how to narrow or browse.
26
+ - **Load `skill:malloy-phrase-detection`**, then call `get_context` with entity targets that describe the fields the question needs: a `measure` for the metric, a `dimension` for each breakdown or filter, and a `view` if the question sounds like a canned report. **One concept per target**: never list several in one `search_text` ("revenue, date, category and region"), because they then compete for one target's few picks. **Every target carries a `search_text`**: a target without one enumerates a capped catalog instead of searching, and the cap is blind to your question. The tool description covers what comes back and how to narrow or browse.
27
+ - **A source in the results is not yet every field you need.** Check that each concept the question needs came back as a field. When one did not, when a target is listed in `unresolved_targets`, or when a warning says results were cut, call `get_context` again with `scopes[].source` set to the source that should hold it and one target per missing concept. Do not answer from a source match alone, and do not tell the user a field is missing before that narrower search.
27
28
  - **A value the user typed (a brand, a city, a product name) is not yet a filter.** Resolve it to the string the data stores before you filter on it: a `dimensional_value` target in that `get_context` call, or, where value search isn't available, a query of the dimension's distinct values. "ray ban" may be stored as `Ray-Ban` and `Ray-Ban Junior`; a `where:` on the user's own spelling, or a regex guessed from it, can match nothing and still return a result.
28
29
  - Read the documentation on each returned entity: on an entity it is `description`, on a source it is `docs`, often with a short `one_line_summary` and sometimes a longer `summary`. Which of these a server fills varies, so read the ones your response actually carries and do not go hunting for one that is absent. Authors write it as `#(doc)` in the model, but the response never uses that label, so do not go looking for that either. It is where grain, units, null handling, and any source-level filters are described. Confirm the exact field names against the results before using them.
29
30
  - **Read the source's own documentation too, not just each field's.** The source-level `docs` often defines the grain, the universe of rows it represents, how joins behave, and source-level filters or assumptions that apply to every query rooted on it. Factor both the source and the field docstrings into how you build and later verify the query.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-analysis-report
3
3
  description: Combine validated Malloy queries into a report (a linear report, read top to bottom). Use when the user asks to "create a report", "combine these into a report", or wants a persistent multi-query artifact.
4
- version: 0.1.47
4
+ version: 0.1.49
5
5
  ---
6
6
 
7
7
  <!--
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-charts
3
3
  description: Read before choosing a chart or adding a chart annotation. Chart types, tag syntax and scale rules, KPI cards, dashboards, sparklines, and the renderer mistakes that fail silently.
4
- version: 0.1.47
4
+ version: 0.1.49
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -0,0 +1,195 @@
1
+ ---
2
+ name: malloy-classify
3
+ description: 'Propose #(classification) annotations for a Malloy model from an access policy''s class scheme, backed by aggregate value evidence from the served data, and show them as a diff before writing anything. Use when the user asks to "classify the model", "tag PHI/PII", "label sensitive fields", or adds fields to a package that a policy governs, and before running malloy-compliance-review on a model nobody has classified.'
4
+ version: 0.1.49
5
+ ---
6
+
7
+ <!--
8
+ Copyright (c) Credible Data Inc.
9
+ SPDX-License-Identifier: MIT
10
+ -->
11
+
12
+ # Classifying a Malloy Model Against a Policy
13
+
14
+ > A classification says which of the policy's data classes a field carries. It is what every
15
+ > other control reads: `#(carries)` on an analytical domain is checked against it at load, the
16
+ > agent sees it on every `get_context` card, and the compliance review checks it against the
17
+ > values. This skill proposes classes. A person approves them. Nothing is written until they do.
18
+
19
+ Two kinds of evidence decide a field's class, and neither is enough alone:
20
+
21
+ - **The values** prove a floor. A column of `123-45-6789` is a Social Security number whatever it
22
+ is called. A text field with one distinct value per patient singles out a person whatever it is
23
+ called. The values cannot say *which* day-level dates the policy treats as direct identifiers.
24
+ - **The policy's class table** decides the rest, by what the field means: its name, its `#(doc)`,
25
+ the column it reads. A diagnosis code is `phi.clinical` because the table says conditions are,
26
+ not because of its values.
27
+
28
+ A field is never proposed below its floor. Above the floor, the class table decides.
29
+
30
+ ## Before you start
31
+
32
+ You need a running Publisher serving the package, and the policy. The profiling runs as a persona
33
+ the policy admits to each domain, so you also need the part of a control register that names the
34
+ classes, the personas and who is admitted where. If the package has a register (usually
35
+ `policies/controls.json`), use it. If not, draft these keys from the policy and show them to the
36
+ user first; `skill:malloy-compliance-review` (`reference/register.md`) describes each one:
37
+
38
+ ```json
39
+ {
40
+ "classes": ["phi.direct_identifier", "phi.quasi_identifier", "phi.clinical", "phi.financial", "none"],
41
+ "identifier_classes": { "direct": ["phi.direct_identifier"], "quasi": ["phi.quasi_identifier"] },
42
+ "subjects": ["patient"],
43
+ "personas": { "clinician": { "givens": { "ROLES": ["clinical"], "ORGANIZATIONS": ["…"] } } },
44
+ "access": { "patient_chart": { "clinician": "scoped" } }
45
+ }
46
+ ```
47
+
48
+ `subjects` names the joins whose records are the individuals the policy protects. A text field
49
+ reached through one of them with a distinct value for nearly every individual is a name, an
50
+ address or a license number, and the floor for it is a direct identifier.
51
+
52
+ ## Workflow
53
+
54
+ ### 1. Read the scheme
55
+
56
+ Find the policy's class table: each class, what it means, and its examples. Use the class names
57
+ exactly as the policy writes them. If the policy has no table, stop and say so: inventing a scheme
58
+ is the policy owner's decision, not yours.
59
+
60
+ ### 2. Collect the evidence (aggregates only)
61
+
62
+ The script lives in `skill:malloy-compliance-review`. From the package directory:
63
+
64
+ ```bash
65
+ C=<skills>/malloy-compliance-review/scripts/compliance.py
66
+ python3 $C profile --environment <env> --package <pkg> --register policies/controls.json --out profile.json
67
+ python3 $C propose --profile profile.json --register policies/controls.json --out proposals.md
68
+ ```
69
+
70
+ `profile` runs one aggregate query per published dimension: counts, distinct counts, lengths,
71
+ pattern-match counts and a numeric range. It never selects a row. `propose` turns that into one
72
+ row per field with its value floor, the basis for it, and how the field is declared today. Read
73
+ `proposals.md`; never read rows of the data into the conversation to classify them. If a domain
74
+ cannot be profiled (no persona admits it), say which, and classify its fields by the class table
75
+ alone.
76
+
77
+ What each signal proves (`skill:malloy-compliance-review` `reference/checks.md` has the thresholds):
78
+
79
+ | Signal | Least class |
80
+ | --- | --- |
81
+ | `ssn`, `email`, `phone`, `uuid` (a record identifier), `unique_per_subject` (one value per protected individual) | direct identifier |
82
+ | `date_day`, `zip5`, `near_unique` (one value per row at the domain's own grain, such as a timestamp) | quasi-identifier |
83
+ | `year`, `zip3`, `free_text` | none: a hint for the class table, never a floor |
84
+
85
+ The verdicts:
86
+
87
+ | Verdict | Meaning | What you do |
88
+ | --- | --- | --- |
89
+ | Declared below what its values prove | The current class is lower than the floor | Propose the floor or higher. This is the finding that matters most |
90
+ | Undeclared | No class yet | Propose one: the floor, or the class table if it says higher |
91
+ | Declared at or above its value floor | Consistent | Keep it unless the class table says higher |
92
+ | A hint, not a floor | Years, three-digit codes, free text | The class table decides; say what the hint was |
93
+ | No value evidence | Nothing in the values | The class table decides, by meaning |
94
+
95
+ ### 3. Decide each field
96
+
97
+ The evidence has one row per field *path*, and a path can stand for several definitions: `patient`
98
+ may join a different patient source on each domain, and `patient.age` may be capped on one and not
99
+ on another. Before deciding, list every definition behind each row (step 4 says how to find them)
100
+ and decide each definition on its own.
101
+
102
+ Measures are not profiled, but every published field needs a class. Decide them by the class table
103
+ and lineage: a measure over a classified field inherits that field's class, and a plain `count()`
104
+ reads nothing, so it needs a declaration; the policy usually says what a count carries.
105
+
106
+ For each field, in order:
107
+
108
+ 1. **Start at the floor.** Never propose below it. If you believe the floor is wrong, say why and
109
+ keep the field at the floor until the user decides.
110
+ 2. **Apply the class table.** Match the field's meaning to the table's rows and examples. When the
111
+ table names the thing (birth date, medical record number), that row wins. When two rows fit, take
112
+ the more protective one and say so. The class table outranks the policy's prose for
113
+ classification: a list of identifiers to remove for de-identification (HIPAA Safe Harbor) is what
114
+ the de-identification controls enforce, not a second class table. Where the two disagree, follow
115
+ the table and mark the field for the policy owner.
116
+ For a field with a `uuid` floor, confirm whose record it is from its `#(doc)` and the join keys
117
+ that read it: a record of a protected individual (a patient, an encounter of a patient) is a
118
+ direct identifier; a record of anything else goes to the policy owner.
119
+ 3. **Apply the derivation rule.** A field derived from a classified one carries its input's class
120
+ unless the policy reduces it (a birth year from a birth date, under a de-identification control).
121
+ Publisher's lineage follows Malloy field references, including through joins, and never raw
122
+ columns: `birth_year is BIRTHDATE.year` reads a column, inherits nothing, and needs its own
123
+ declaration, while `birth_year is birth_date.year` inherits `birth_date`'s class. Do not declare
124
+ a class a field already inherits. Where the policy reduces one, declare the reduced class and
125
+ name the control that allows it in the field's `#(doc)`.
126
+ 4. **Propose, then ask** when the class table does not name a field. Propose the most protective
127
+ class that fits its meaning and mark it for the policy owner with one question. Demographics,
128
+ state, units and specialties are typical. Never leave a field unproposed.
129
+ 5. **One class per regime.** A list is for a field under several regimes at once
130
+ (`["phi.direct_identifier", "gdpr.personal"]`), not for two classes of the same scheme. When two
131
+ classes of one scheme fit, step 2 decides.
132
+
133
+ ### 4. Place each declaration where the field is defined
134
+
135
+ A class is declared on the dimension or measure that defines the field, usually in the package's
136
+ core sources rather than on each analytical domain. A field defined on a domain itself is
137
+ classified there. A field reached through a join takes
138
+ the class declared where it lives, so `patient.ssn` is classified on `ssn` in the patient source,
139
+ not on every domain that joins it. Find the definition with the `.malloy` files: for each domain
140
+ the row lists, follow the join named by the path into the source that domain joins, and take the
141
+ last segment's definition there. Two domains may join two different sources under one name; each
142
+ definition gets its own annotation.
143
+
144
+ The form is one quoted class, or a quoted, comma-separated list when a field sits under several
145
+ regimes. One `#(classification)` per field; a second is malformed:
146
+
147
+ ```malloy
148
+ #(doc) The patient's Social Security number.
149
+ #(classification) "phi.direct_identifier"
150
+ dimension: ssn is SSN
151
+
152
+ #(classification) ["phi.direct_identifier", "gdpr.personal"]
153
+ dimension: email is EMAIL
154
+ ```
155
+
156
+ `docs/classification.md` is the reference, including how lineage and `#(carries)` read these.
157
+
158
+ ### 5. Show the diff and wait
159
+
160
+ Present one table, every field that would change first:
161
+
162
+ | Field | Defined in | Now | Value floor | Proposed | Basis |
163
+ | --- | --- | --- | --- | --- | --- |
164
+ | `patient.first_name` | `core.malloy` `patients_core` | `none` | direct | `phi.direct_identifier` | One value per patient; the table lists names |
165
+ | `conditions.condition` | `core.malloy` `conditions_core` | -- | -- | `phi.clinical` | No value evidence; the table lists conditions |
166
+
167
+ `--` under Now means undeclared; `none` means declared `none`.
168
+
169
+ Then the fields that keep their class, briefly, and every field you need the policy owner to
170
+ answer. **Do not edit a `.malloy` file until the user approves.** Approval of some rows is approval
171
+ of those rows only.
172
+
173
+ ### 6. Write, reload, verify
174
+
175
+ After approval:
176
+
177
+ 1. Add the annotations to the defining sources.
178
+ 2. `reload_package` (or `GET …/packages/<pkg>?reload=true`). A reload that fails is safe: the
179
+ previous model keeps serving and the errors come back. A `#(carries)` refusal here means a
180
+ domain now exposes a class it does not declare; that is the classification working. Report it,
181
+ do not widen `#(carries)` to make it go away; widening a domain's ceiling is a policy decision.
182
+ 3. Check `get_status` (or `GET /api/v0/status`) for a `stale: true` entry.
183
+ 4. Run the review's coverage and value checks, which fail any published field left unclassified or
184
+ declared below its values: `python3 $C check … --out-dir review`, then read `review/report.md`.
185
+
186
+ ## Rules
187
+
188
+ - **Aggregates only.** Classify from `profile` output and the class table. A sample of rows from a
189
+ field that might hold identifiers is the disclosure the classification exists to prevent.
190
+ - **Never below the floor**, and never lower an existing class without the user's explicit approval
191
+ of that row. Lowering a class can admit data to a domain that excluded it.
192
+ - **The policy's vocabulary, exactly.** Publisher reads the shape and never the words, so a typo is
193
+ a new class that no `#(carries)` lists.
194
+ - **One place per field.** Declare at the definition; let lineage carry it.
195
+ - **Propose, don't write.** The output of this skill is a diff a person approves.
@@ -0,0 +1,123 @@
1
+ ---
2
+ name: malloy-compliance-review
3
+ description: 'Check a Malloy package and its controls against a written access policy: turn the policy''s prose into a control register, run compliance.py check against a served package (structure, access as each role, and the values themselves), judge what needs a person, and produce a report and a signable attestation. Use when the user asks whether a model "complies with", "satisfies" or "meets" a policy, HIPAA, SOC 2, PCI or the like, asks for a compliance review or attestation, or wants the review in CI.'
4
+ version: 0.1.49
5
+ ---
6
+
7
+ <!--
8
+ Copyright (c) Credible Data Inc.
9
+ SPDX-License-Identifier: MIT
10
+ -->
11
+
12
+ # Reviewing a Malloy Package Against an Access Policy
13
+
14
+ > A policy is prose: who may read which data, and under what conditions. A compliant package is
15
+ > controls a machine checks: gates on the analytical domains, classifications on the fields, a
16
+ > ceiling on what each domain carries. This skill connects the two. It writes the policy down as a
17
+ > register of checks, runs them against the served package, and reports each control as holding,
18
+ > failing, needing a person, or not provable from the model at all.
19
+
20
+ The review has two halves, and they are kept apart on purpose:
21
+
22
+ - **The register** (`policies/controls.json`) is the policy as checks. You draft it from the prose;
23
+ the policy owner approves it. It changes when the policy changes.
24
+ - **The check** (`scripts/compliance.py check`) runs the register against the package as it is
25
+ served. It changes nothing, reads only aggregates, and exits 1 when a control fails, so the same
26
+ command gates every change in CI.
27
+
28
+ What Publisher already refuses at load (a domain exposing a class its `#(carries)` omits, a gate
29
+ on a given the caller can set, a join that reaches a gated source around its gate) the check
30
+ reports as the control it breaks. It does not re-derive them.
31
+
32
+ ## Before you start
33
+
34
+ - A Publisher serving the package, reachable over REST (default `http://localhost:4000`). Run the
35
+ review against a server with **no gateway in front**: each persona's givens are sent with the
36
+ request and honored, which is how the check asks "what would this role see". Never point it at a
37
+ production endpoint, where a trusted tier sets those givens and the probe would be meaningless.
38
+ - The policy document.
39
+ - A classified model. If fields are unclassified, `skill:malloy-classify` first.
40
+
41
+ ## Workflow
42
+
43
+ ### 1. Draft the register from the policy
44
+
45
+ Read the whole policy. For each numbered requirement, write one control with its id and text as
46
+ the policy states them, and the checks that prove it. Then the context the checks need: the class
47
+ scheme, the personas the policy names, what each persona may read on each domain, and the rules on
48
+ particular domains (de-identified, never carries a class, a cap). `reference/register.md` is the
49
+ format and `reference/checks.md` says what each check proves.
50
+
51
+ Rules for drafting:
52
+
53
+ - **One control per requirement, in the policy's words.** The attestation quotes them back.
54
+ - **A control with no check is `not_provable_here`**, with a `why` naming who can prove it (a log
55
+ store's retention, a training record). Saying so is a result, not a gap to paper over.
56
+ - **Name a persona for every workforce class the policy names**, and one that should be refused
57
+ (a partner without an agreement). The refusals are half of what the access check proves.
58
+ - **Givens come from the model.** A persona's givens are the model's `#(secure)` givens, named
59
+ exactly; `get_context` lists them on each domain, and the package read lists every one under `secureGivens`.
60
+
61
+ Show the register to the user and stop for approval. It is the policy owner's statement of what
62
+ the policy means; you drafted it.
63
+
64
+ ### 2. Run the check
65
+
66
+ From the package directory:
67
+
68
+ ```bash
69
+ python3 <skills>/malloy-compliance-review/scripts/compliance.py check \
70
+ --environment <env> --package <pkg> --register policies/controls.json --out-dir review
71
+ ```
72
+
73
+ It reads the package read's classification summary, the model read's gates, `get_context` over
74
+ the MCP endpoint (`--mcp`, default `http://127.0.0.1:4040/mcp`) and `GET /api/v0/status`. It probes
75
+ every published domain as every persona with a count, queries every source the package does not
76
+ publish (read from the `.malloy` files in the current directory, or `--package-dir`) to confirm it
77
+ refuses, and profiles every published dimension with aggregates (seconds to a minute for 140
78
+ fields). It writes `review/report.json` and `review/report.md`. Exit 0 means no
79
+ control fails.
80
+
81
+ ### 3. Judge what needs a person
82
+
83
+ Read `review/report.md`. `reference/report.md` explains each section. For each control:
84
+
85
+ - **Holds**: report it with its evidence. Do not restate it as stronger than the evidence.
86
+ - **FAILS**: name the domain and field from the finding, the requirement it breaks, and the
87
+ smallest change to the model that would fix it. A failure the server refused at load (the report
88
+ says the package is serving a stale model) is already contained: the change was not deployed.
89
+ Say both.
90
+ - **Needs review**: a value check that could not decide (free text on a de-identified domain, a
91
+ probe that errored). Look at the evidence and say what you concluded and why; if it needs the
92
+ policy owner, say so.
93
+ - **Not provable here**: say who can prove it.
94
+
95
+ Do not fix the model in the same step. Report first; a fix is a separate change that goes through
96
+ the same review.
97
+
98
+ ### 4. Attest
99
+
100
+ ```bash
101
+ python3 <skills>/malloy-compliance-review/scripts/compliance.py attest \
102
+ --report review/report.json --out review/attestation.md
103
+ ```
104
+
105
+ The attestation lists every control with its status and signature lines for the policy owner.
106
+ It is generated from the report, not edited: if a status is wrong, fix the register or the model
107
+ and rerun.
108
+
109
+ ### 5. In CI
110
+
111
+ Run the same `check` on every change to a `.malloy` file, against a Publisher started on the
112
+ change's checkout, and fail the build on exit 1. `docs/compliance-review.md` has a GitHub Actions
113
+ example. Upload `review/` as the build's artifact so the evidence travels with the change.
114
+
115
+ ## Rules
116
+
117
+ - **Aggregates only.** The check never selects a row. Do not add a query that does, and do not
118
+ read rows to investigate a finding: the finding's evidence is a count for that reason.
119
+ - **The register is approved, not inferred.** Never change an expected outcome to make a check
120
+ pass. A check that fails because the register is wrong is fixed by the policy owner.
121
+ - **Report, then fix.** A review that edits what it reviews proves nothing.
122
+ - **Exact names.** Domain, persona and given names in the register must match the served model;
123
+ the check refuses an unknown check name and reports an unknown domain as a failure.
@@ -0,0 +1,50 @@
1
+ <!--
2
+ Copyright (c) Credible Data Inc.
3
+ SPDX-License-Identifier: MIT
4
+ -->
5
+
6
+ # The checks
7
+
8
+ Each check reads one of four things: the package read's classification summary (each published
9
+ domain's `#(carries)` and its field count by class), the model read (each domain's gates and which
10
+ givens are `#(secure)`), `get_context` (each published dimension with its classes), or the server
11
+ status. The rest are aggregate queries run as the register's personas. None reads a row.
12
+
13
+ | Check | Fails when | Reads |
14
+ | --- | --- | --- |
15
+ | `inventory` | The package read has no classification summary, or `get_context` could not list the published fields | package read, `get_context` |
16
+ | `loads` | The package did not load, or serves a stale model because its latest files were refused. One finding per refusal, tagged with the domain and kind (`ceiling`, `secure`, `join`) | `/api/v0/status` |
17
+ | `refusals` | Never fails on a refusal: a refused change is evidence that a violating change is not deployed | `/api/v0/status` |
18
+ | `coverage` | A published field has no class | package read |
19
+ | `gated` | A published domain has neither `#(authorize)` nor a row filter | model read |
20
+ | `secure_gates` | A gate reads a given that is not `#(secure)` | model read |
21
+ | `locked_bases` | A source the package does not publish answers a count to an identity holding every role the register names. Its sources are read from the package's `.malloy` files and queried through each file's own path; with no files found it needs review | package files, a count per source |
22
+ | `ceiling` | A domain declares no `#(carries)`, declares a class its `must_not_carry` forbids, or serves a class beyond it | package read, register |
23
+ | `access` | A persona's outcome on a domain differs from the register, or the register names a domain the package does not publish | a count per domain per persona |
24
+ | `values` | A field's values prove more than its class allows, a de-identified domain serves day dates, five-digit ZIPs or uncapped values. Free text there needs review | `get_context`, an aggregate profile per field |
25
+
26
+ ## What the values prove
27
+
28
+ `values` runs one aggregate query per published dimension, as a persona admitted to its domain:
29
+ the row count, the filled count, the distinct count, lengths, and how many values match each
30
+ pattern. A signal counts when at least half the filled values match.
31
+
32
+ | Signal | Means | Least class |
33
+ | --- | --- | --- |
34
+ | `ssn`, `email`, `phone` | The values are that kind of identifier | direct |
35
+ | `uuid` | Record identifiers | direct |
36
+ | `unique_per_subject` | A text field through a `subjects` join with a distinct value for at least 85% of the individuals that have one, over 50 or more | direct |
37
+ | `date_day` | Dates to the day | quasi |
38
+ | `zip5` | Five-digit ZIP codes | quasi |
39
+ | `near_unique` | At the domain's own grain, distinct values for at least 90% of 500 or more rows | quasi |
40
+ | `year`, `zip3`, `free_text` | Hints only; never fail a class | -- |
41
+
42
+ A floor is a lower bound. A birth date's values show dates to the day, a quasi-identifier; the
43
+ policy's class table makes it direct. The check fails a field declared *below* its floor, never one
44
+ declared above.
45
+
46
+ ## Scoping
47
+
48
+ A check runs once per review, however many controls name it. A control that names `values:research`
49
+ takes only the findings about `research`; if every finding is about another domain, the control
50
+ holds for that check and the report says nothing it found touches `research`.
@@ -0,0 +1,74 @@
1
+ <!--
2
+ Copyright (c) Credible Data Inc.
3
+ SPDX-License-Identifier: MIT
4
+ -->
5
+
6
+ # The control register
7
+
8
+ The register is a policy written as checks: one JSON file, usually `policies/controls.json` in the
9
+ package, beside the policy it was drafted from. `compliance.py` reads it; nothing in Publisher
10
+ does. [`examples/clinical-records/policies/controls.json`](../../../examples/clinical-records/policies/controls.json)
11
+ is a complete one for a HIPAA minimum-necessary policy.
12
+
13
+ ## Keys
14
+
15
+ | Key | Required | What it is |
16
+ | --- | --- | --- |
17
+ | `policy` | yes | Path to the policy document, relative to the package. Quoted in the report. |
18
+ | `version` | yes | The policy version or approval date the register was drafted from. |
19
+ | `classes` | yes | Every class the policy defines, exactly as `#(classification)` spells them. |
20
+ | `identifier_classes` | yes | `{"direct": [...], "quasi": [...]}`: which classes identify a person outright and which in combination. The value checks read these. |
21
+ | `subjects` | no | Join names whose records are the individuals the policy protects (`["patient"]`). A text field through one of them with a distinct value per individual must be a direct identifier. |
22
+ | `personas` | yes | `{id: {"class": "...", "givens": {...}}}`: one per workforce class the policy names, plus the ones it refuses. `givens` are the model's `#(secure)` givens, named exactly. |
23
+ | `access` | yes | `{domain: {persona: outcome}}`: what each persona gets from each published domain. An omitted pair means `refused`. |
24
+ | `domains` | no | Rules on particular domains, below. |
25
+ | `controls` | yes | The policy's requirements, below. |
26
+
27
+ ### Access outcomes
28
+
29
+ | Outcome | Means | Proven by |
30
+ | --- | --- | --- |
31
+ | `rows` | Admitted, with every row | The count probe answers, and the domain has no row filter |
32
+ | `scoped` | Admitted, with only the rows its givens select | The count probe answers, and the model read shows a row filter in force |
33
+ | `refused` | Not admitted | The count probe answers 403 |
34
+
35
+ `scoped` is read from the model's row filter, never from a smaller count: a persona may be the only
36
+ one admitted to a domain and still be scoped.
37
+
38
+ ### Domain rules
39
+
40
+ ```json
41
+ "research": {
42
+ "deidentified": true,
43
+ "must_not_carry": ["phi.direct_identifier"],
44
+ "caps": [{ "field": "patient.age", "max": 90 }]
45
+ }
46
+ ```
47
+
48
+ - `deidentified`: the domain may serve no day-level date, no five-digit ZIP, and nothing that reads
49
+ as free text.
50
+ - `must_not_carry`: classes the domain's `#(carries)` may not list.
51
+ - `caps`: a numeric field's maximum, as the values show it.
52
+
53
+ ### Controls
54
+
55
+ ```json
56
+ { "id": "MN-04", "text": "A researcher reads de-identified data and nothing else.",
57
+ "checks": ["access:research", "ceiling:research", "values:research"] }
58
+ ```
59
+
60
+ - `id` and `text` are the policy's, verbatim.
61
+ - `checks` name what proves the control. A check scoped with `:domain` takes only that domain's
62
+ findings; `loads:` also takes a refusal kind (`ceiling`, `secure`, `join`).
63
+ - A control nothing in the model can prove takes `"status": "not_provable_here"` and a `why`.
64
+ - `why` on any control is printed in the report beside it.
65
+
66
+ `reference/checks.md` lists every check.
67
+
68
+ ## Drafting it
69
+
70
+ Work requirement by requirement. For each, ask what in a served package would show it holding:
71
+ a gate (`gated`, `secure_gates`), a classification (`coverage`, `values`), a ceiling (`ceiling`),
72
+ an answer to a role (`access`), a refusal (`loads`). If the answer is "nothing in the package", it
73
+ is `not_provable_here`; name who proves it. Show the draft to the policy owner before the first
74
+ run, and treat their edits as the policy's meaning.