Guide · Metadata Enrichment Engine

Get data owners to approve AI-written column descriptions

DuckDB 1.5.62026-11-05Code ran before publishing, PDF by email

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 duckdb package. 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 queue
QUEUE = """
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.

Python and SQL: the four checks
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.

Python and SQL: the status
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.
Python: the four planted faults
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.

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.

See the Metadata Enrichment Engine