Get data owners to approve AI-written column descriptions
The short answer
Send each AI-written column description to the owner of the business glossary term it came from. Four plain SQL checks then confirm that the right person decided and that an approved row still matches its draft. In a demo on eight invented drafts, five were marked Publish, two waited, one was rejected and none were blocked.
Download owner_approval.py · Get the full guide (PDF)
What you need
- Python 3 and the
duckdbpackage. Nothing else; the script makes its own demo tables in memory. - Four tables of your own, when you move to your data: a glossary (term, definition, owner email), the owners (email, name, active or not), the AI drafts (table, column, draft text, the glossary term used, date drafted) and the review log (table, column, reviewer email, decision, final text, date decided).
The demo uses eight column names from the public FEC individual-contributions file layout (read 2026-11-05). The drafts, glossary, people and decisions are invented, and the addresses use example.com. The AI step itself is not in the script: it starts from drafts that already exist, from any tool, and handles the part after. In your own pipeline the glossary can do two jobs: give the drafting tool the agreed meaning of a term, and name the person who owns that term.
Step 1: give every draft an owner from the glossary
Join each draft to its glossary term, ignoring case and leading or trailing spaces, and take the owner from the term. Then attach the decision with the latest date for that column, if there is one. The window function runs in a QUALIFY clause (read 2026-11-05), which filters on a window function's result without a subquery.
Python and SQL: the review queueQUEUE = """
CREATE TEMP TABLE queue AS
SELECT d.table_name, d.column_name, d.draft_text, d.glossary_term, d.drafted_on,
g.owner_email,
r.reviewer_email, r.decision, r.final_text, r.decided_on
FROM src_drafts d
LEFT JOIN src_glossary g ON lower(trim(g.term)) = lower(trim(d.glossary_term))
LEFT JOIN (
SELECT * FROM src_review_log
QUALIFY row_number() OVER (PARTITION BY table_name, column_name ORDER BY decided_on DESC) = 1
) r ON r.table_name = d.table_name AND r.column_name = d.column_name;
"""
Step 2: check the review log with plain SQL
Four checks, each a query that returns the rows that break it. They are ordinary SQL, not a second AI call. Where a value can be missing, they use IS NULL or IS DISTINCT FROM, which DuckDB documents (read 2026-11-05) as treating NULL as a value you can compare, so an owner or reviewer that is missing shows up as a failure instead of vanishing from the result.
CHECKS = [
("1 owner",
"the draft's glossary term exists and its owner is an active person",
"""SELECT q.table_name, q.column_name FROM queue q
WHERE q.owner_email IS NULL
OR NOT EXISTS (SELECT 1 FROM src_owners o
WHERE lower(trim(o.email)) = lower(trim(q.owner_email)) AND o.is_active)"""),
("2 reviewer",
"the person who decided is the term's owner",
"""SELECT table_name, column_name FROM queue
WHERE decision IS NOT NULL
AND lower(trim(reviewer_email)) IS DISTINCT FROM lower(trim(owner_email))"""),
("3 text",
"an approved row publishes the draft unchanged; an edited row differs from it and is not empty",
"""SELECT table_name, column_name FROM queue
WHERE (decision = 'approved' AND final_text IS DISTINCT FROM draft_text)
OR (decision = 'edited' AND (final_text IS NULL OR trim(final_text) = '' OR final_text = draft_text))"""),
("4 order",
"every decision and every decided draft has a date, the latest decision is alone on its date, and that decision came on or after the draft",
"""SELECT table_name, column_name FROM queue
WHERE decision IS NOT NULL AND (decided_on IS NULL OR drafted_on IS NULL OR decided_on < drafted_on)
OR (table_name, column_name) IN (
SELECT table_name, column_name FROM (
SELECT table_name, column_name, decided_on,
max(decided_on) OVER (PARTITION BY table_name, column_name) AS latest
FROM src_review_log)
GROUP BY table_name, column_name
HAVING count(*) FILTER (WHERE decided_on IS NULL) > 0
OR count(*) FILTER (WHERE decided_on = latest) > 1)"""),
]
Step 3: give each draft one status, and list only the Publish rows
A draft is Blocked if it breaks any of the four checks, or if its decision is anything other than approved, edited or rejected (the match is exact, so "Approved" blocks). Otherwise it is Waiting (no decision yet), Rejected (the owner said no) or Publish (approved, or edited by the owner). The script stops if the number of statuses differs from the number of drafts. Only Publish rows are printed as ready to load into your catalog, and an edited row lists the owner's text, not the AI's. Run it with --fail-on-blocked and it exits with an error when any row is Blocked, which is how a pipeline stops.
STATUS = """
CREATE TEMP TABLE status AS
SELECT q.*,
CASE
WHEN b.column_name IS NOT NULL THEN 'Blocked'
WHEN q.decision IS NULL THEN 'Waiting'
WHEN q.decision = 'rejected' THEN 'Rejected'
WHEN q.decision IN ('approved', 'edited') THEN 'Publish'
ELSE 'Blocked'
END AS status
FROM queue q
LEFT JOIN (SELECT DISTINCT table_name, column_name FROM blocked) b
ON b.table_name = q.table_name AND b.column_name = q.column_name;
"""
Two runs of the demo: clean, then with four planted faults
We ran the script twice on the invented demo data, once as it is and once with four faults added to the review log. Each fault trips one check:
- Wrong reviewer (a decision signed by another owner) trips check 2.
- Text changed after approval (the approved row no longer matches the draft) trips check 3.
- Decision before draft (dated before the draft existed) trips check 4.
- Term not in glossary (a misspelled term, so no owner) trips check 1.
PLANTS = [
("wrong reviewer", "UPDATE demo_review_log SET reviewer_email = 'LI.CHEN@example.com' WHERE column_name = 'CMTE_ID'"),
("text changed after approval",
"UPDATE demo_review_log SET final_text = 'Committee code (needs a rewrite).' WHERE column_name = 'TRANSACTION_DT'"),
("decision before draft", "UPDATE demo_review_log SET decided_on = DATE '2026-10-01' WHERE column_name = 'EMPLOYER'"),
("term not in glossary", "UPDATE demo_drafts SET glossary_term = 'Contributr job' WHERE column_name = 'MEMO_TEXT'"),
]
Output from our run: python3 owner_approval.py --plant-error
planted: wrong reviewer
planted: text changed after approval
planted: decision before draft
planted: term not in glossary
Checks
check 1 owner: FAIL (1 rows) - the draft's glossary term exists and its owner is an active person
indiv.MEMO_TEXT
check 2 reviewer: FAIL (1 rows) - the person who decided is the term's owner
indiv.CMTE_ID
check 3 text: FAIL (1 rows) - an approved row publishes the draft unchanged; an edited row differs from it and is not empty
indiv.TRANSACTION_DT
check 4 order: FAIL (1 rows) - every decision and every decided draft has a date, the latest decision is alone on its date, and that decision came on or after the draft
indiv.EMPLOYER
Review queue
indiv.CMTE_ID ana.reyes@example.com approved Blocked
indiv.EMPLOYER sam.okafor@example.com approved Blocked
indiv.FILE_NUM ana.reyes@example.com approved Publish
indiv.MEMO_TEXT (no owner) - Blocked
indiv.OCCUPATION sam.okafor@example.com rejected Rejected
indiv.SUB_ID ana.reyes@example.com - Waiting
indiv.TRANSACTION_AMT li.chen@example.com edited Publish
indiv.TRANSACTION_DT li.chen@example.com approved Blocked
Counts
Publish 2
Waiting 1
Rejected 1
Blocked 4
checks failed: 4
Ready to load into the catalog (Publish rows only)
indiv.FILE_NUM: Identifier of the report the row came from.
indiv.TRANSACTION_AMT: Dollar amount of one itemized contribution, as reported on the filing.
All four checks failed, one row each, and the four rows are Blocked. None of them is in the list for the catalog. The rows still waiting or rejected keep those statuses; MEMO_TEXT, which had no decision, is Blocked because its term has no owner.
Output from our run: python3 owner_approval.py (clean)Checks check 1 owner: pass (0 rows) - the draft's glossary term exists and its owner is an active person check 2 reviewer: pass (0 rows) - the person who decided is the term's owner check 3 text: pass (0 rows) - an approved row publishes the draft unchanged; an edited row differs from it and is not empty check 4 order: pass (0 rows) - every decision and every decided draft has a date, the latest decision is alone on its date, and that decision came on or after the draft Review queue indiv.CMTE_ID ana.reyes@example.com approved Publish indiv.EMPLOYER sam.okafor@example.com approved Publish indiv.FILE_NUM ana.reyes@example.com approved Publish indiv.MEMO_TEXT ana.reyes@example.com - Waiting indiv.OCCUPATION sam.okafor@example.com rejected Rejected indiv.SUB_ID ana.reyes@example.com - Waiting indiv.TRANSACTION_AMT li.chen@example.com edited Publish indiv.TRANSACTION_DT li.chen@example.com approved Publish Counts Publish 5 Waiting 2 Rejected 1 Blocked 0 checks failed: 0 Ready to load into the catalog (Publish rows only) indiv.CMTE_ID: Nine-character code that identifies the committee receiving the contribution. indiv.EMPLOYER: Employer the contributor stated on the filing. indiv.FILE_NUM: Identifier of the report the row came from. indiv.TRANSACTION_AMT: Dollar amount of one itemized contribution, as reported on the filing. indiv.TRANSACTION_DT: Date the contribution was made.
No check failed. Five descriptions are marked Publish, two are waiting for their owner and one was rejected. A person decides every one of them; the checks only confirm that the right person decided and that an approved row still matches its draft.
What a person does with a Blocked row
A Blocked row goes back to its owner, or to whoever manages the glossary. A wrong reviewer means the row is re-routed to the term's owner. A changed text means the owner approves the new wording or the draft goes back unchanged. A decision dated before the draft means the owner decides again on the current draft. Two decisions on the same date, or a missing date, mean the review log or the draft is given the right date. A term that is not in the glossary means somebody fixes the term or adds it, which also gives it an owner. A decision value such as "Approved" means the log entry is corrected, and an inactive owner means the term is reassigned.
Use it on your own data
To run it on your own data, load your glossary, owners, AI drafts and review log into one DuckDB file, set DB_FILE to that file's path, and in the four CREATE VIEW statements in SOURCES replace each table name after FROM and each column name before AS so every view still returns the columns named after AS. The decision column must hold approved, edited or rejected. Leave everything else as it is.
Each glossary term must appear once, and each column must have one draft: two entries for the same term, or two drafts of the same column, stop the run. The script reads your tables through views named src_glossary, src_owners, src_drafts and src_review_log, so it stops if your file already holds a table or view with one of those names. DuckDB creates a new empty file if the path is wrong, so check the path. The script creates only temporary views and tables, so your file is not changed. A glossary term with no owner, an owner marked inactive, a missing reviewer email and an email in a different case are all handled: the first two fail check 1, a missing reviewer fails check 2, and the case of an email is ignored. The decision values must be lowercase. We tested these edits on a database with different table and column names, a NULL owner and a NULL reviewer, and each case was flagged as described above. The planted-fault flag works only on the demo data.
Python: the part you edit# ---- YOUR DATA ---------------------------------------------------------------------------
# DB_FILE = None builds the invented demo tables in memory. To use your own data, set DB_FILE to
# a DuckDB file that holds your four tables, then change the table and column names inside the
# four CREATE VIEW statements in SOURCES so each view returns exactly the columns named after AS.
# The views are named src_... so they cannot clash with your own tables (glossary, drafts and so on).
DB_FILE = None
SOURCES = """
CREATE VIEW src_glossary AS
SELECT term AS term, definition AS definition, owner_email AS owner_email FROM demo_glossary;
CREATE VIEW src_owners AS
SELECT email AS email, full_name AS full_name, is_active AS is_active FROM demo_owners;
CREATE VIEW src_drafts AS
SELECT table_name AS table_name, column_name AS column_name, draft_text AS draft_text,
glossary_term AS glossary_term, drafted_on AS drafted_on FROM demo_drafts;
CREATE VIEW src_review_log AS
SELECT table_name AS table_name, column_name AS column_name, reviewer_email AS reviewer_email,
decision AS decision, final_text AS final_text, decided_on AS decided_on FROM demo_review_log;
"""
# ---- END YOUR DATA -----------------------------------------------------------------------
Get the full guide as a PDF
We'll email you a link to it. Please use your work email.
Thanks, . We're sending the link to now. If it isn't there in a few minutes, check your spam or junk folder, or email info@datameaning.com.
Tested on
Tested on
- Platform
- DuckDB 1.5.6 with Python 3, in memory. DuckDB opens or creates a database file at the path you give it, and an in-memory database is lost when the process ends (DuckDB connection overview, read 2026-11-05).
- Date
- 2026-11-05
- Dataset
- Generated records. Eight column names come from the FEC individual-contributions file layout (read 2026-11-05); the descriptions, glossary terms, people and decisions are invented. It is a demonstration of the mechanism, not a measurement.
- Code
- owner_approval.py
What the owner-approval script does not do
- The checks do not judge the wording. They confirm who decided and that an approved row matches its draft. They cannot tell whether a description is correct; the owner does that.
- An edit is not verified. For an edited row the script checks only that the text is not empty and differs from the draft. It cannot tell whether the owner wrote it or whether it changed afterwards.
- An email is not an identity. The reviewer is whatever address the review log holds. If anyone can write to that log, anyone can claim to be the owner. Your tool needs to sign people in.
- One decision per column. The latest decision date wins. Two decisions on that same date, a decision with no date, or a decided draft with no date fail check 4, and the column stays Blocked until the review log or the draft is corrected. The script keeps no history and has no second approver.
- No reminders. A Waiting row stays Waiting until someone looks. Nothing here sends a message or chases an owner.
- The script does not load anything. It prints the rows ready for your catalog; the load is yours.
Want owner review on your own columns? See the Metadata Enrichment Engine
This guide ran owner review on eight public column names with invented drafts. The Metadata Enrichment Engine delivers AI-driven contextualized business descriptions, sensitivity classifications and data quality rules for a data domain, with your data owners deciding on each description.