skills-ws
Version:
85 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
425 lines (362 loc) • 22.8 kB
Markdown
name: data-management
description: "Data governance, ELT/ETL pipeline design, warehouse modeling, data quality, contracts, lineage, and privacy compliance for analytics teams. Use when designing a warehouse/dbt project, defining data quality tests or contracts, setting up ownership/RACI and PII classification, or implementing GDPR retention/erasure and access controls."
# Data Management
## Workflow
### 1. Pipeline Architecture
**Batch vs streaming:**
| Approach | Latency | Use case | Tools |
|----------|---------|----------|-------|
| Batch ETL | Hours | Daily reporting, historical analysis | Airflow, dbt, Fivetran |
| Micro-batch | Minutes | Near-real-time dashboards | Spark Streaming, dbt + scheduler |
| Streaming | Seconds | Real-time alerts, live feeds | Kafka, Flink, Kinesis |
**Decision:** Start with batch. Move to streaming only when business requires sub-minute latency.
**Standard pipeline pattern:**
```
Sources → Extract → Landing/Raw → Transform → Staging → Serve → BI/Analytics
↓ ↓ ↓ ↓ ↓ ↓
APIs Fivetran Raw zone dbt models Clean Looker/
DBs Airbyte (immutable) (versioned) tables Metabase
Files Custom S3/GCS SQL tests Views API
```
### 2. Warehouse Schema Design
**Declare the grain first.** A fact table's grain is the business meaning of one row. Every measure and foreign key must be true at that grain. The most common modeling bug is mixing header-level and line-level facts in one table — an order with 3 products is 1 order but 3 lines, so `order_id` cannot be a unique key at line grain.
```sql
-- FACT (line grain): one row per order line. PK is the line, NOT the order.
-- Additive measures (quantity, revenue) live here.
CREATE TABLE fact_order_lines (
order_line_key BIGINT PRIMARY KEY, -- surrogate, one per line
order_id BIGINT NOT NULL, -- degenerate dimension (header id)
customer_key INT NOT NULL REFERENCES dim_customers(customer_key),
product_key INT NOT NULL REFERENCES dim_products(product_key),
order_date_key INT NOT NULL REFERENCES dim_dates(date_key),
quantity INT NOT NULL,
unit_price DECIMAL(12,2) NOT NULL,
line_revenue DECIMAL(12,2) NOT NULL, -- quantity * unit_price - line_discount
line_discount DECIMAL(12,2) NOT NULL DEFAULT 0,
loaded_at TIMESTAMP NOT NULL
);
-- FACT (header grain): one row per order. PK = order_id.
-- Put header-only measures here (shipping, order-level discount). Do NOT sum these
-- after joining to lines or you fan-out and double-count — keep grains separate.
CREATE TABLE fact_orders (
order_id BIGINT PRIMARY KEY, -- header grain ⇒ order_id is unique
customer_key INT NOT NULL REFERENCES dim_customers(customer_key),
order_date_key INT NOT NULL REFERENCES dim_dates(date_key),
order_total DECIMAL(12,2) NOT NULL, -- sum of line_revenue at load time
shipping_amount DECIMAL(12,2) NOT NULL DEFAULT 0, -- header-only, non-additive across lines
order_discount DECIMAL(12,2) NOT NULL DEFAULT 0,
line_count INT NOT NULL,
loaded_at TIMESTAMP NOT NULL
);
```
Rule of thumb: report line-level metrics from `fact_order_lines`, header-level metrics (AOV, shipping) from `fact_orders`. If you must combine, aggregate one fact to the other's grain first (CTE), never join-then-sum.
```sql
-- Date dimension (pre-populated, one row per calendar day)
CREATE TABLE dim_dates (
date_key INT PRIMARY KEY, -- YYYYMMDD integer, e.g. 20260607
full_date DATE NOT NULL,
year INT, quarter INT, month INT, week INT,
day_of_week VARCHAR(10),
is_weekend BOOLEAN,
is_holiday BOOLEAN
);
```
**Star vs snowflake:**
- Star: denormalized dimensions, faster queries, easier to understand. **Use this.**
- Snowflake: normalized dimensions, saves storage, more joins. Only if storage is a concern (rarely).
**Slowly Changing Dimensions (SCD Type 2 — done correctly).** `is_current` alone is NOT SCD2. A real Type-2 dimension keeps full history: a new versioned row on every tracked-attribute change, with validity bounds and exactly one current row per natural key.
```sql
CREATE TABLE dim_customers (
customer_key BIGINT PRIMARY KEY, -- surrogate key, unique per VERSION (e.g. hash or identity)
customer_id VARCHAR(50) NOT NULL, -- natural/business key (repeats across versions)
name VARCHAR(200),
email VARCHAR(200),
segment VARCHAR(50),
country VARCHAR(50),
valid_from TIMESTAMP NOT NULL, -- when this version became effective
valid_to TIMESTAMP NOT NULL DEFAULT TIMESTAMP '9999-12-31 00:00:00', -- open-ended for current
is_current BOOLEAN NOT NULL DEFAULT TRUE,
row_hash VARCHAR(64) NOT NULL -- hash of tracked cols, detects real changes
);
-- Enforce "one current row per natural key" (partial index where supported):
CREATE UNIQUE INDEX uq_dim_customers_current
ON dim_customers (customer_id) WHERE is_current;
-- Facts join on the surrogate customer_key valid at the order's date → preserves point-in-time truth.
```
Upsert/merge logic. **Order matters: INSERT new versions first, then close the old ones** — if you close first, the change-detection join below can no longer find the prior current row and the logic only works by accident. Computing the change-set against the *current* version keeps both branches meaningful:
```sql
-- 1) Insert a new current version for new OR changed natural keys.
-- Compared against the still-current row, so change detection is unambiguous.
INSERT INTO dim_customers (customer_key, customer_id, name, email, segment, country,
valid_from, valid_to, is_current, row_hash)
SELECT
md5(s.customer_id || '|' || s.loaded_at::text)::uuid::text, -- deterministic surrogate
s.customer_id, s.name, s.email, s.segment, s.country,
s.loaded_at, TIMESTAMP '9999-12-31', TRUE,
md5(concat_ws('||', s.name, s.email, s.segment, s.country))
FROM stg_customers s
LEFT JOIN dim_customers d
ON d.customer_id = s.customer_id AND d.is_current
WHERE d.customer_id IS NULL -- brand-new customer
OR d.row_hash <> md5(concat_ws('||', s.name, s.email, s.segment, s.country)); -- changed
-- 2) Close the PREVIOUS current version for keys that just got a newer version.
-- A key now has >1 is_current row; expire every current row except the latest valid_from.
UPDATE dim_customers d
SET valid_to = newer.valid_from, is_current = FALSE
FROM (
SELECT customer_id, MAX(valid_from) AS valid_from
FROM dim_customers WHERE is_current
GROUP BY customer_id HAVING COUNT(*) > 1
) newer
WHERE d.customer_id = newer.customer_id
AND d.is_current
AND d.valid_from < newer.valid_from; -- keep only the newest as current
```
Wrap both statements in one transaction so the dimension is never observed with two current rows. The partial unique index above is your safety net — it will reject the load if step 2 fails to expire a stale row.
In dbt, prefer the built-in `snapshot` (`strategy='check'` or `'timestamp'`), which generates `dbt_valid_from`/`dbt_valid_to`/`dbt_scd_id` for you instead of hand-writing the merge.
### 3. dbt Project Structure
```
models/
staging/ -- 1:1 with source tables, rename/cast/clean
stg_stripe_payments.sql
stg_hubspot_contacts.sql
_stg__sources.yml -- source freshness + raw-table contracts
intermediate/ -- business logic joins
int_customer_orders.sql
marts/ -- final tables for BI
dim_customers.sql
fact_orders.sql
fact_order_lines.sql
metrics_monthly_revenue.sql
_marts__models.yml -- tests, descriptions, contracts
snapshots/ -- SCD Type 2 history (dbt snapshot blocks)
customers_snapshot.sql
packages.yml -- dbt-utils, dbt-expectations
```
Add `dbt_utils` and `dbt_expectations` to `packages.yml` (run `dbt deps`); they supply the realistic data-quality tests used below:
```yaml
# packages.yml
packages:
- package: dbt-labs/dbt_utils
version: [">=1.3.0", "<2.0.0"]
- package: metaplane/dbt_expectations
version: [">=0.10.0", "<1.0.0"]
```
**dbt model example:**
```sql
-- models/marts/dim_customers.sql
WITH customers AS (
SELECT * FROM {{ ref('stg_hubspot_contacts') }}
),
orders AS (
SELECT customer_id, MIN(order_date) AS first_order, COUNT(*) AS total_orders, SUM(revenue) AS ltv
FROM {{ ref('stg_stripe_payments') }}
GROUP BY customer_id
)
SELECT
c.customer_id,
c.name,
c.email,
c.segment,
c.country,
o.first_order,
o.total_orders,
o.ltv,
CASE WHEN o.ltv > 1000 THEN 'high' WHEN o.ltv > 100 THEN 'medium' ELSE 'low' END AS value_tier
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
```
### 4. Data Governance
Governance is who owns what, who can see what, and how you prove it. Keep it lightweight but concrete.
**Ownership & RACI.** Assign every dataset/domain an owner and steward; review quarterly.
| Role | Responsibility |
|------|----------------|
| Data Owner (business) | Accountable for the dataset, approves access, sets retention/classification |
| Data Steward | Responsible for quality, definitions, fixing issues, maintaining docs/tests |
| Data Engineer | Builds/operates pipelines; consulted on schema changes |
| Consumers (analysts/PM) | Informed of changes/SLAs via changelog + freshness alerts |
**PII / data classification tiers.** Tag every column; the tag drives masking, retention, and access.
| Tier | Examples | Controls |
|------|----------|----------|
| Public | product catalog, public metrics | none |
| Internal | aggregated revenue, internal IDs | role-based access |
| Confidential / PII | name, email, IP, device id | column masking, access review, retention limit |
| Restricted / sensitive | payment data, health, gov ID, special-category (GDPR Art. 9) | encryption, least-privilege, audit log, DPIA |
In dbt, classify in the model YAML with `meta` so it propagates to the catalog and downstream policies:
```yaml
columns:
- name: email
meta: { pii: true, classification: confidential, masking_policy: email_mask }
- name: customer_id
meta: { classification: internal }
```
**Access control (modern warehouse patterns).** Grant roles, never individuals. Use the warehouse's native fine-grained controls instead of building views per team:
- **Column masking** (dynamic data masking): show `j***@x.com` to analysts, full value to a `pii_reader` role. Snowflake `MASKING POLICY`, BigQuery column-level data masking, Databricks Unity Catalog column masks.
- **Row access policies / row-level security**: restrict rows by region/tenant. Snowflake `ROW ACCESS POLICY`, BigQuery row-level security, Databricks row filters, Postgres RLS.
```sql
-- Snowflake masking policy: full email only for the pii_reader role
CREATE MASKING POLICY email_mask AS (val STRING) RETURNS STRING ->
CASE WHEN CURRENT_ROLE() IN ('PII_READER') THEN val
ELSE REGEXP_REPLACE(val, '^[^@]+', '****') END;
ALTER TABLE dim_customers MODIFY COLUMN email SET MASKING POLICY email_mask;
```
- Run a **quarterly access review**: list grants per role, confirm with each Data Owner, revoke unused. Log who approved.
**Lineage & catalog.** Know where every field comes from and who consumes it before you change anything.
- dbt already produces column/model lineage via `ref()`/`source()` → expose it with **dbt docs** (`dbt docs generate`) or push the manifest to a catalog.
- Catalog options (as of Jun 2026, verify current features): **DataHub** or **OpenMetadata** (open-source), **Unity Catalog** (Databricks), or **dbt Catalog/Explorer** if on dbt Cloud. Catalog stores: owner, classification, freshness SLA, description, lineage.
**Data contracts.** A contract is an enforced agreement on a producer's schema so upstream changes can't silently break you. dbt enforces this at build time:
```yaml
models:
- name: stg_stripe_payments
config:
contract: { enforced: true } # build FAILS if a column type/name drifts from below
columns:
- name: customer_id
data_type: varchar
constraints: [{ type: not_null }]
- name: amount
data_type: numeric
```
Pair with **source freshness** so stale upstream data is caught automatically:
```yaml
sources:
- name: stripe
freshness: { warn_after: {count: 12, period: hour}, error_after: {count: 24, period: hour} }
loaded_at_field: _loaded_at
tables: [{ name: payments }]
```
**Incident workflow.** When a quality test/freshness check fails:
1. **Detect** — test/freshness alert fires to the data on-call channel (see Monitoring).
2. **Triage** — assess blast radius via lineage (which dashboards/marts consume the broken model); set severity.
3. **Contain** — pause/hold the affected job, mark stale dashboards, notify consumers (the "Informed" row above).
4. **Fix & backfill** — correct source/transform, re-run, validate the failing test now passes.
5. **Post-mortem** — for SEV1/2, write a blameless root-cause + add a regression test so the same break is caught next time.
### 5. Data Quality Framework
**Quality dimensions:**
| Dimension | Definition | Check |
|-----------|-----------|-------|
| Completeness | No missing required values | `WHERE column IS NULL` count |
| Accuracy | Values are correct | Spot-check against source, range validation |
| Consistency | Same value across systems | Compare CRM vs billing vs product DB |
| Timeliness | Data is fresh enough | `MAX(updated_at)` vs expected freshness |
| Uniqueness | No unintended duplicates | `COUNT(*) vs COUNT(DISTINCT key)` |
| Validity | Values match expected format | Regex, enum validation, range checks |
**dbt tests (add to `_marts__models.yml`).** Note on syntax: dbt Core 1.10+ standardizes test args under an `arguments:` block (the older flat style still parses but is being deprecated). Shown in current style below.
`accepted_values` is the WRONG tool for email validity — `values: []` compiles to a degenerate predicate and tests nothing. Validate format with a regex test from `dbt_expectations`, or `dbt_utils.expression_is_true` for an adapter-portable check:
```yaml
version: 2
models:
- name: dim_customers
columns:
- name: customer_id
data_tests:
- not_null
- unique # natural key uniqueness applies to the CURRENT version
- name: email
data_tests:
- not_null
# Correct email-format check (regex). Adjust pattern as needed.
- dbt_expectations.expect_column_values_to_match_regex:
arguments:
regex: "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"
config:
severity: warn # warn (don't fail the build) on dirty source emails
- name: segment
data_tests:
- accepted_values: # accepted_values IS correct for a known enum
arguments:
values: ['enterprise', 'mid-market', 'smb', 'self-serve']
# Grain assertion: order_id must be unique only at HEADER grain.
- name: fact_orders
columns:
- name: order_id
data_tests: [not_null, unique]
# At LINE grain, order_id repeats — assert the SURROGATE is unique instead,
# and the (order_id, product_key) combination is unique.
- name: fact_order_lines
columns:
- name: order_line_key
data_tests: [not_null, unique]
data_tests:
- dbt_utils.unique_combination_of_columns:
arguments:
combination_of_columns: ['order_id', 'product_key']
```
If you cannot add packages, a custom singular test under `tests/` is the portable fallback — it fails when any row is invalid:
```sql
-- tests/assert_valid_email.sql (returns offending rows ⇒ test fails if any)
SELECT customer_id, email
FROM {{ ref('dim_customers') }}
WHERE email IS NOT NULL
AND email NOT SIMILAR TO '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'
```
**Data quality score (make each dimension measurable first).** A weighted score is only meaningful if every input is a normalized 0–100% pass rate computed the same way. Define each as `passing_rows / evaluated_rows` over a fixed window (e.g. last 24h of loads):
| Dimension | Measured as (0–100%) | Example metric |
|-----------|----------------------|----------------|
| Completeness | rows with all required cols populated ÷ total rows | `1 - (nulls_in_required / total)` |
| Validity | rows passing format/range/enum tests ÷ total | `valid_email + valid_enum + in_range / total` |
| Uniqueness | `COUNT(DISTINCT key) / COUNT(*)` on natural key | 1.0 = no dupes |
| Consistency | rows matching across systems ÷ reconciled rows | CRM vs billing customer match rate |
| Timeliness | 1 if `MAX(updated_at)` within SLA else 0 (or % of partitions fresh) | freshness pass rate |
| Accuracy | % of a sampled audit set matching source of truth | manual/spot-check sample |
```sql
-- Each component returns a 0..1 ratio; combine with documented weights.
WITH q AS (
SELECT
1.0 - (COUNT(*) FILTER (WHERE email IS NULL)::numeric / COUNT(*)) AS completeness,
AVG((email SIMILAR TO '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}')::int) AS validity,
COUNT(DISTINCT customer_id)::numeric / COUNT(*) AS uniqueness
FROM dim_customers WHERE is_current
)
SELECT ROUND(100 * (completeness*0.3 + validity*0.25 + uniqueness*0.1
/* + consistency*0.2 + timeliness*0.15 from their own queries */), 1)
AS quality_score_pct
FROM q;
```
Weights are a business choice — document them next to the metric. Target `> 95%` only after each component is defined and sampled consistently; an undefined "accuracy" makes the target meaningless.
### 6. Privacy & Compliance (GDPR + AI Act)
**Data subject rights checklist:**
| Right | Implementation |
|-------|---------------|
| Access (Art. 15) | Export all personal data within 30 days |
| Rectification (Art. 16) | Allow users to correct their data |
| Erasure (Art. 17) | Delete personal data on request (right to be forgotten) |
| Portability (Art. 20) | Provide data in machine-readable format |
| Restriction (Art. 18) | Stop processing but retain data |
| Objection (Art. 21) | Opt out of marketing/profiling |
**Data retention — example template, NOT legal limits.** GDPR sets no fixed retention numbers; it requires *storage limitation* (Art. 5(1)(e)) — keep data only as long as needed for the stated purpose, then delete or anonymize. The "periods" below are common defaults that must be adjusted per **jurisdiction**, **lawful basis**, and **data-minimization**; statutory periods (tax, employment) vary by country. Verify each with counsel/your DPO.
| Data type | Typical default* | Lawful basis (Art. 6) | Caveats |
|-----------|------------------|-----------------------|---------|
| Account data | Contract + (1–3y) | Contract / legal obligation | Local limitation periods differ |
| Payment/invoice records | Country tax law (often 6–10y) | Legal obligation | E.g. DE/LU ~10y, varies — confirm locally |
| Analytics events | Minimize; pseudonymize early | Consent or legitimate interest | "26 months" is a GA default, not a GDPR rule; needs LI balancing test or consent |
| Marketing consent log | Until withdrawn + proof window | Consent | Keep the consent *record* to prove it |
| Support tickets | As needed (e.g. 1–3y) | Legitimate interest | Strip PII when no longer needed |
| Deleted-account grace | 30d then purge from prod + backups | Erasure right | Define backup-deletion path (below) |
\* Illustrative only — set real values with your DPO per applicable law.
**Erasure must propagate (don't forget backups & downstream).** A delete in prod that lingers in the warehouse/backups/third-parties is non-compliant. Maintain a deletion runbook: prod DB → analytics warehouse (and marts derived from it) → search indexes/caches → backups (document the rolling-backup expiry as the deletion mechanism) → processors (Stripe, email, support tools) via their delete APIs. Log every erasure (subject id hash, date, systems cleared) for accountability.
**Consent management:**
- Record: what, when, how, and version of consent text (store the consent log as proof)
- Allow granular consent (analytics, marketing, third-party separately)
- Make withdrawal as easy as giving consent
- Re-consent on material changes to privacy policy
**Accountability & cross-border (the documents regulators ask for).**
- **RoPA** (Art. 30): Record of Processing Activities — what data, why, who, retention, recipients.
- **DPIA** (Art. 35): Data Protection Impact Assessment for high-risk processing (large-scale profiling, special-category data, systematic monitoring).
- **DPA** (Art. 28): Data Processing Agreement with every processor/sub-processor (your warehouse, ETL vendor, email tool).
- **International transfers** (Ch. V): for EU→non-adequate-country flows, use **SCCs** (Standard Contractual Clauses) + a **TIA** (Transfer Impact Assessment). EU↔US: rely on the **EU-US Data Privacy Framework** only if the vendor is certified. Pin your warehouse/processor *region* to keep data in-region where feasible.
- **Audit log**: keep an immutable log of access to PII and of erasure/rectification actions.
**EU AI Act — data-governance touchpoints (as of Jun 2026; phased obligations are rolling in — verify current status at https://artificialintelligenceact.eu).** If your pipelines feed model training or automated decisions:
- Training/validation data for high-risk AI must meet **data governance** requirements (Art. 10): relevance, representativeness, examination for bias, documented provenance.
- **GDPR still applies** to personal data used for training — you need a lawful basis and must honor erasure; prefer anonymized/pseudonymized training sets and record data lineage so you can show provenance.
- Keep dataset documentation (sources, classification tier, consent/lawful basis) in the catalog alongside the data — this doubles as AI-Act and GDPR evidence.
> This section is engineering guidance, not legal advice. Retention periods, lawful bases, and AI-Act applicability depend on your jurisdiction and use case — confirm with a qualified DPO/legal counsel.
### 7. Monitoring
**Automated alerts:**
- Pipeline failure (any step) → Slack/PagerDuty immediate
- Data freshness > expected SLA → warn after 1 hour, alert after 4 hours
- Quality score drops below 90% → alert data team
- Duplicate rate > 1% → alert
- Schema change detected in source → alert (breaking changes)