Report contract → Databricks App
The second consumer of the governed reporting core. A report skill renders the contract offline; this makes the same contract interactive on Databricks Apps — the same SQL files, the same parameters, the same execution identities.
This skill is an adapter, not an app builder. It owns exactly one thing: getting the contract into the app correctly and keeping it there. Scaffolding, screen design and deployment belong to skills that already do them well, and this skill loads them rather than reimplementing them.
When to use
- A validated contract (or a report skill built from one) should become an interactive app.
- The app and the report disagree on a number.
- Someone edited the app's SQL by hand and it needs to go back under the contract.
- CI should fail when the app's copy drifts.
When NOT to use
- An app with no contract behind it →
databricks-apps. This skill has nothing to materialize. - How the screens should look →
databricks-app-design(KPI composition, charts, states, Genie trust). Load it alongside; do not design here. - Bundle structure, targets, permissions →
databricks-dabs. - A managed dashboard →
databricks-aibi-dashboards. "Build me a dashboard" is not this skill. - Generating the report skill →
report-skill-builder.
Workflow
Validate the contract first. An app built on an invalid contract is a faster way to be wrong.
python3 "${CLAUDE_SKILL_DIR}/../governed-report-contract/scripts/validate_contract.py" reports/<name>Load the delegated skills.
databricks-corefor auth and profile selection,databricks-appsfor scaffolding,databricks-app-designbecause this app displays data. Ask the user for the profile; never auto-select one.Scaffold or locate the app. Read the manifest before building the init command — plugin keys and resource fields come from it, not from memory:
databricks apps manifest --profile <PROFILE> databricks apps init --name <name> --features analytics \ --set analytics.sql-warehouse.id=<WAREHOUSE_ID> --run none --profile <PROFILE>Materialize the contract. This is the skill's actual job.
python3 "${CLAUDE_SKILL_DIR}/scripts/materialize_app.py" \ --contract reports/<name> --app apps/<app>Queries are copied byte-for-byte into
config/queries/with the.obo.sqlsuffix intact,metric-views/definitions.jsonis bound, andconfig/report.manifest.jsonrecords the contract version and a SHA-256 per file. Queries dropped from the contract are removed from the app.Generate types from the warehouse, then commit them:
export DATABRICKS_WAREHOUSE_ID=<id> npx @databricks/appkit generate-types --waitBuild the screens with
databricks-app-design, binding every element to aqueryKeyfrom the contract or auseMetricViewkey. Do not add a query in the app: aSELECTthat exists only inApp.tsxis exactly the drift this whole design prevents.Gate drift in CI:
python3 scripts/materialize_app.py --contract reports/<name> --app apps/<app> --checkValidate, deploy, verify — mechanics per
databricks-apps:databricks apps validate --profile <PROFILE> databricks apps get <app-name> --profile <PROFILE> -o json # app_status.state, urlSmoke-check parity. Run the report skill and the app with the same parameters and the same identity and compare a KPI from each block. This is a smoke check, not a proof: it does not cover every parameter combination, filter, or cache state. Record the contract version, the manifest digest, the attested principal and the watermark alongside the numbers.
Output spec
apps/<app>/config/queries/*.sql— byte-identical to the contract, suffixes preservedapps/<app>/config/metric-views/definitions.json— bound semantic layerapps/<app>/config/report.manifest.json— contract version + per-file SHA-256shared/appkit-types/*.d.ts— generated and committed- A drift check wired into CI, exiting non-zero
- A stated parity result: same contract version, same numbers, or a named reason why not
Gotchas
- Renaming
x.obo.sqltox.sqlis a data-exposure change, not a tidy-up. It converts a per-user query into one shared, service-principal result cached for everyone. The materializer preserves the suffix; never "simplify" it afterwards. - Editing
config/queries/by hand is the drift. It is a generated directory. Change the contract and re-materialize;--checkexists to make the hand-edit fail loudly. - An added query is worse than an edited one.
config/queries/is runnable in full, so a hand-addedx.sqlis ungoverned SQL executing as the service principal — including a copy of an OBO block with its per-user isolation removed.--checkcompares what the app can actually run against the contract, not just what the manifest lists, and re-materializing deletes the extra. x.sqlandx.obo.sqlare the same query key. Shipping both makes one block claim two execution identities; the materializer refuses rather than letting AppKit pick.useMetricViewneedsdefinitions.jsonto exist. Without it every metric key returns404, which looks like a routing bug and is a config one.- Order metric measures by their SELECT alias, not
MEASURE(...), and always pairlimitwithorderBy— otherwise the card shows a different row set run to run. LIMITbound withsql.number()breaks past 2^31 by widening toBIGINT. Usesql.int().DECIMALand bigBIGINTarrive as strings despite the generated types. Coerce before formatting, or the app quietly rendersNaNwhere the report shows a number.- Committed types are a CI fallback, not a substitute. A missing
metric-views.d.tsturns a cold warehouse into a failed build. - Different numbers with an in-sync manifest means execution identity or freshness — check the attested principal and the watermark before suspecting the SQL.
References
- references/app-adapter.md — the full division of labour, AppKit type generation and its failure taxonomy, metric-view routing rules, parameter binding, the DAB variables that make dev/prod reproducible, and the CI gate.
scripts/materialize_app.py— copy + manifest +--checkdrift gate (--selftestproves it catches hand-edits, stale copies, version bumps and removals).