Crosshire
Classification & PII
Govern17 Classification & PII · 1 of 3

Marking the data

Two mechanisms, in this order: declare what you know in the DDL, then let a scan find what you did not. Neither protects anything on its own — a tag is a handle for the policies in the access route to grab, and the two mechanisms do not hand you the same handle.

Stand 29.07.2026

#Tagging the three columns that matter

What
The canonical model flags exactly three columns on gold.dim_customer as personal data: email, phone, street. The contact columns are where a named employee appears. customer_name is not among them, and the reason has a limit worth stating out loud: this is a B2B distributor, so the customer name is usually a company name, and a legal person is outside the GDPR. Usually is not always — see the erasure topic, where that limit becomes a column.
Those three columns take the three governed pii values declared once at account level in the access route — email, phone, address. There is deliberately no fourth value parked in advance for a contact-person column. ALTER GOVERNED TAG pii SET VALUES (…) is declarative: the list you supply replaces the existing one, so adding name the day such a column arrives is a one-line change shipped in the same pull request as the column. A value declared years early is a value no policy references, which is precisely the decoration the access route tells you not to ship.
Why
A classification written in a wiki page protects nothing. A classification written as a tag is the join key between 'this column is personal data' and every mask, access review and audit query that has to act on it — one policy on the catalog covers every table carrying the tag, including tables nobody has built yet.
The concrete failure here is the second customer table. Six months in, somebody builds a customer extract for a marketing use case. Under per-table masks it is naked until a human remembers. Under tag-driven policy, if the tag ships in the same CREATE TABLE as the column, the new table is protected on its first write.
How
sql
The tag is applied in the DDL that ships the column
-- The governed tag and its permitted values are created ONCE at account
-- level (see the access route); ASSIGN is granted to a group, not to everyone.
--   CREATE GOVERNED TAG pii VALUES ('email', 'phone', 'address');
-- Adding a value later is one declarative statement — the new list REPLACES
-- the old one, so never hand-edit it from memory:
--   ALTER GOVERNED TAG pii SET VALUES ('email', 'phone', 'address', 'name');

SET TAG ON COLUMN dwh_prod.gold.dim_customer.email   pii = email;
SET TAG ON COLUMN dwh_prod.gold.dim_customer.phone   pii = phone;
SET TAG ON COLUMN dwh_prod.gold.dim_customer.street  pii = address;

-- Use SET TAG, in DDL. NOT:
--   ALTER TABLE ... ALTER COLUMN email SET TAGS ('pii' = 'email')
-- That is the free-text tag syntax: it validates nothing against the governed
-- value list, so it accepts a value CREATE GOVERNED TAG would have refused.
-- Whether an assignment made that way is ALSO invisible to ABAC is not settled
-- here: the docs say creating a governed tag over a key that already carries
-- free-text assignments converts those assignments to governed ones, which
-- reads like one shared namespace rather than two. Reproduce it on the client
-- workspace before asserting either version to them. SET TAG is the statement
-- specified to do this job; the argument above is why it is also the safe one.
Governed is the operative word: the value list is fixed at account level and applying a tag needs an explicit permission, so nobody invents pii = kinda_sensitive on a Friday afternoon. The drift check is one query, and it belongs in CI:
sql
Columns that look like personal data and carry no classification
SELECT c.table_name, c.column_name
FROM   dwh_prod.information_schema.columns AS c
LEFT   JOIN dwh_prod.information_schema.column_tags AS t
       ON  t.schema_name = c.table_schema
       AND t.table_name  = c.table_name
       AND t.column_name = c.column_name
       AND t.tag_name    = 'pii'
WHERE  c.table_schema IN ('silver', 'gold')
  AND  c.column_name RLIKE '(email|phone|mobile|street|birth|iban)'
  AND  t.tag_value IS NULL;   -- any row here fails the build
Doing it
  • Tag in the same pull request as the columnA tag clicked into Catalog Explorer exists in prod and not in dwh_dev, so nobody tests against the mask until production.
  • Classify silver.customer tooPersonal data does not become personal at the gold boundary, and analysts with silver access read the same email addresses.
  • Keep the value list to the three the access route declaresExtend it in the pull request that needs the fourth. A vocabulary of fifteen gets policies written against a handful of them, and then no one can say which columns are uncovered without running the drift query.
  • Run the untagged-lookalike query in CI over silver and gold, and fail the build on a non-empty result
Embedding it
  • Have the team classify dim_customer themselves before showing them your list, then diffThe disagreement is always customer_name, and that argument is the one worth having — it should end in a legal-form column, not in a verdict.
  • Get one engineer to add a contact column and watch CI catch itA check they have seen fire is a check they trust.
  • Put the value list under the DPO's name and let the team take it to themA classification engineering owns alone gets overruled at the worst possible moment.
  • When someone proposes tagging everything, hand them the query that counts the columns and let the number argue
Defaults
  • Tags applied in DDL, in the same commit as the column, in all three catalogs
  • Governed tags with a fixed value list; ASSIGN granted to a group
  • Classify from silver onward, not only in gold
  • Every tag value is referenced by at least one policyAn unreferenced value is decoration.
Gotchas
  • Tagging gold.dim_customer and forgetting silver.customerThe standing access review over PII-tagged tables then reports full coverage, because the untagged table is not in its scope — the report is clean precisely because the gap is invisible to it.
  • Tags applied by hand in the UIdwh_dev has no tag, so the mask never evaluates there, the developer's test passes, and the first time anyone sees the masked column is in production.
  • A value list that growsA policy matching has_tag_value('pii', 'email') does not match a column tagged email_marketing, and governed values stop you inventing a value outside the list — they do not stop someone adding one to it, which is one ALTER GOVERNED TAG away and replaces the whole list in passing. The symptom is a column that is correctly tagged and completely unmasked.
  • Settling customer_name as 'a company name, therefore not personal data'In a German B2B base that is wrong for the Einzelkaufleute (e.K.), Freiberufler and GbR/OHG partners: they are natural persons, their firm name carries the owner's surname, and they hold an Art. 17 right. The column is personal data for a subset of rows, which no column-level tag can express — so the answer is a legal-form column, not a firmer sentence in the concept. Assert the absolute in the kick-off and the DPO corrects you in front of the client.
  • Rebuilding a table to change a column typeThe replacement column arrives untagged, because the tag is bound to the column and not to its name, so the mask stops applying the moment the migration runs and nothing errors. The untagged-lookalike query in CI is what catches that; a quarterly review is not.

#Automated classification as a net, not a plan

What
Automated data classification scans Unity Catalog tables and applies governed tags to columns that look like personal data. It is GA alongside ABAC, and it is an agentic scan over the catalog rather than a regex you maintain. Two scope limits shape the design around it. Views and metric views are not supported, so gold.mv_sales is outside its reach entirely and only gold.fct_order_line underneath it is scanned. And detection exclusions — teaching it to stop reporting a false positive — were Beta at the 29.07.2026 pass; that sub-feature is not in the currency register, so check the classification doc below before you design a review queue on top of it.
It will find email on gold.dim_customer, which you tagged in DDL anyway. The finding you actually want is the contact address sitting in a free-text field of a new bronze.shop_order attribute that arrived with schema evolution last Tuesday.
There are two ways to close that gap and you have to pick one explicitly. Either a second policy written over the classifier's own namespace, so a detection is protected the moment it is tagged — the condition the docs themselves recommend:
Why
The reason to run it is bronze. bronze.shop_order lands JSON with schema evolution, so a new field appears whenever the webshop team ships, and nobody tells the warehouse. A classification scan is the only mechanism in this architecture that notices.
The reason not to lean on it is timing. A scan runs on a schedule; a column exists from its first write. In the window between the two the data is present and unclassified — and that is exactly the window in which someone builds the report that reads it.
How
sql
Option A — a policy over class.*, so findings protect before a human arrives
CREATE POLICY mask_classified_pii
ON SCHEMA dwh_prod.gold
COMMENT 'Redact what the classifier found, until a steward maps it onto pii'
COLUMN MASK dwh_prod.gold.mask_pii
TO `account users`
EXCEPT `dwh_pii_readers`, `dwh_prod_pipelines`
FOR TABLES
MATCH COLUMNS has_tag('class.name')
           OR has_tag('class.email_address')
           OR has_tag('class.phone_number') AS class_col
ON COLUMN class_col;

-- Count the conditions: three, and three is the documented ceiling per
-- MATCH COLUMNS clause. A fourth classification needs a second policy, and two
-- column-mask policies that both resolve on one column error instead of
-- masking — for the analyst, on a dashboard, not for whoever added the policy.
Or the steward route: every confirmed finding is mapped by hand onto a pii value with SET TAG, and the class.* tag stays what it is — evidence that a scan saw something. That keeps one policy and one vocabulary, at the cost of a human between detection and protection. Option A protects sooner and masks false positives too; the steward route never masks a column nobody looked at. Write down which one this warehouse runs, because the failure mode of assuming the other is a catalog full of correct findings and no mask.
Two more facts belong in the estimate rather than in a footnote. The scan is billed: usage lands in system.billing.usage under billing_origin_product = 'DATA_CLASSIFICATION', and the initial scan costs more than the ones after it. 'Enable it on dwh_prod and, separately, on dwh_dev' is therefore two initial scans and a recurring line item, not a checkbox. And the results are persisted: system.data_classification.results holds them for 13 months, readable by account admins only unless somebody grants further, and the rows contain sample values lifted out of your tables.
That last point sharpens this route's own GDPR argument rather than complicating it. The entry in the record of processing activities is not 'a scan reads samples of personal data'. It is: samples of personal data are copied into a metastore-wide system table and retained for thirteen months, outside the catalog whose grants you designed and outside the masks you attached to it. Write that sentence and the DPO signs; write the weaker one and they find the stronger one themselves.
Doing it
  • Decide the class.* question before the first scanSecond policy over the classifier namespace, or a steward mapping findings onto pii values. Until one of the two exists, the scan protects exactly nothing.
  • Enable it on dwh_prod and, separately, on dwh_dev — and price both initial scans before you promise itA class.email_address finding in dev is how you discover the production copy somebody made.
  • Set the catalog state per classification, on purpose, and record itAuto-tagging Active means every detection is tagged unreviewed; Inactive means every detection waits on a person who has to exist.
  • Add system.data_classification.results to the processing register and to the access reviewIt holds sample values of personal data for 13 months and is account-admin-only by default, which is a grant decision somebody has to make rather than inherit.
  • Treat a bronze finding as a source-contract ticket, not just a tagging ticketA new personal-data field means the source changed shape.
  • Keep DDL tags as the primary mechanismThe scan is the net underneath, not the plan.
Embedding it
  • Run the first scan on dev with the team watchingIt usually finds something embarrassing, and it is much better that they find it than that you do.
  • Ask them to predict the findings before it runsThe gap between prediction and result is an honest measure of how well they know their own data.
  • Have them query system.data_classification.results themselves and read a sample value out loudThe 13-month retention argument lands in one second and never needs making again.
  • Hand the mapping — class.* finding to pii value, or the review of the class.* policy — to their data stewardIf it sits on your calendar it stops the week you leave.
Defaults
  • DDL tags first, automated classification as the safety net
  • One written decision on how class.* findings reach a mask: second policy, or steward mapping. Never neither
  • Scan dev and test as well as prod, with the initial-scan cost in the budget
  • A named reviewer and a fixed cadence, written into the anonymisation conceptThe same entry records the catalog state each classification runs in.
  • Bronze findings open a conversation with the source team, not just a ticket
Gotchas
  • Believing a finding is a maskThe classifier writes class.email_address; the schema policy matches has_tag('pii'). Both behave exactly as documented, the column is served raw, and the findings dashboard climbs while the number of protected columns stays flat — the most convincing false assurance in this route.
  • Turning auto-tagging on to skip the review stepEvery existing and future detection of that classification gets tagged, false positives included, and a governed tag applied at scale is a governed tag somebody unpicks at scale — with ASSIGN permission they probably do not have.
  • Quoting scan coverage that includes gold.mv_salesViews and metric views are not supported, so the metric view is never scanned; only the tables under it are. Coverage percentages have to be stated over tables, and the view layer inherits its tags by hand or not at all.
  • The free-text columnA comment field containing 'Frau Weber unter 0170… anrufen' is personal data no classifier reliably catches and no column mask can partially redact. The fix is not to land free text in gold at all.
  • Switching the scan on without touching the processing registerIt is billed processing that copies sample values of personal data into system.data_classification.results for 13 months. The finding at the sign-off meeting is then not a mis-tagged column — it is an undocumented processing activity, raised by the DPO in the meeting that was meant to approve your concept.
  • Building the review workflow on detection exclusionsWithout them the same false positives return on every scan, the queue is forty rows of the same thing, and within two months nobody opens it — so the one genuine bronze finding sits unconfirmed among them. With them, the workflow rests on a Beta surface. Pick which of the two you are living with and write it in the concept.