
Data lineage you can defend, part 2: AI-assisted lineage on AWS
*How to build AI-assisted, human-validated data lineage on AWS. A hands-on guide covering native lineage capture with Amazon SageMaker Catalog, mainframe analysis with AWS Transform, a code-parsing agent on AWS Step Functions and Amazon Bedrock, a human review workflow, and validated lineage exposed to AI agents through Amazon Bedrock AgentCore.*
Series: Part 1: AI proposes, people decide · Part 2 of 2 · Level: 400 (Expert) · Reading time: ~15 minutes
Key terms in this article Lineage edge: a directed link saying "column X in dataset B is derived from column Y in dataset A, by transformation T". OpenLineage: an open standard for lineage events (runs, jobs, datasets, facets). SageMaker Catalog ingests it natively. HITL: human-in-the-loop. A person approves, edits, or rejects machine-proposed edges. Distributed Map: an AWS Step Functions state that runs thousands of child workflows in parallel over a dataset in Amazon S3. ETL: extract, transform, load. JCL / VSAM: mainframe job control language and file storage format. SME: subject matter expert. DLQ: dead-letter queue. MCP: Model Context Protocol, an open standard for exposing tools to AI agents. NL2SQL: natural language to SQL.
Recap: the target design
In Part 1 I argued that trustworthy lineage needs several independent detectors (runtime capture, code analysis, metadata, data similarity) merged into one graph, under a simple rule: the LLM proposes, evidence and humans decide. Edges that are deterministic or corroborated are accepted automatically. Low-confidence edges, and any edge feeding a regulated report, go to a human with evidence attached.
This part shows how to build that on AWS. Most of it is configuration of managed services. Three components need real engineering: the code-parsing agent, the confidence and review loop, and the agent-facing access layer, and most of this part focuses on those.
Prerequisites
To follow along, you need:
- An AWS account with permissions to create AWS Step Functions state machines, AWS Lambda functions, Amazon DynamoDB tables, and Amazon S3 buckets
- An Amazon SageMaker Unified Studio domain, with permission to call
datazone:PostLineageEventon it
- Model access in Amazon Bedrock for an Anthropic Claude model and an embedding model
- AWS Glue 5.0 or later for Spark jobs whose lineage you want captured natively
- Python 3.12 with
boto3andsqlglotpackaged into the Lambda deployment (for example, as a Lambda layer)
- A sample of the legacy SQL, stored procedures, or batch code behind one report, uploaded to an S3 bucket
Cost note: the services in this post incur charges. Run the pilot in a non-production account and follow the Cleanup section when you finish.
Solution overview
Figure 1 shows the end-to-end architecture.

Figure 1: Detectors A–D run in parallel and emit OpenLineage events. A normaliser merges and scores them, low-confidence edges go to human review, and validated edges land in SageMaker Catalog. Auditors, migration teams, and AI agents all read the same graph.
Layer | AWS services | Build or configure? |
|---|---|---|
Native capture (A) | AWS Glue
5.0+,
Amazon EMR
,
Amazon Redshift
→
SageMaker Catalog lineage | Configure |
Mainframe analysis (B) | AWS Transform for mainframe | Configure + adapter |
Code-parsing agent (C) | AWS Step Functions
(Distributed Map),
AWS Lambda
,
Amazon Bedrock | Build |
Similarity detector (D) | AWS Glue Data Quality
, Amazon Bedrock embeddings,
Amazon S3 Vectors | Build (light) |
Merge, score, review | Lambda, Step Functions Amazon Augmented AI (Amazon A2I) waitForTaskToken ,
or a small review app,
Amazon DynamoDB
, Amazon S3 Object Lock | Build |
System of record | Amazon SageMaker Catalog (lineage API), optionally
Amazon Neptune | Configure |
Agent access | Amazon Bedrock AgentCore
Gateway + AgentCore Policy (Cedar) | Build (thin) |
Step 1: Collect the free lineage first
Before writing any AI code, turn on everything AWS captures natively. This gives you a high-confidence baseline that later detectors are checked against.
Data lineage in SageMaker Unified Studio is OpenLineage-compatible. It shows lineage captured automatically from supported tools and connections, and lineage you publish yourself through the
PostLineageEvent API, with column-level tracing in the visualiser.AWS Glue Spark. OpenLineage libraries are built into AWS Glue 5.0+. Check the limits in the troubleshooting guide before relying on this:
Gotcha | Impact | Mitigation |
|---|---|---|
Spark DataFrames only, not Glue DynamicFrames | Older Glue jobs emit nothing | Use DataFrames when you next touch the job; until then, cover it with detector C |
Jobs writing to raw S3 paths produce S3 datasets, not Glue tables | Graph nodes don't link to catalog assets | Read and write through Glue tables |
300 KB event size limit | Wide tables fail to publish | Tune the column-lineage Spark settings described in the guide |
Fine-grained permission mode not supported | No lineage from those jobs | Detector C fallback |
Domain in a different Region from the job | Events go nowhere | Set spark.openlineage.transport.region |
Amazon EMR Spark. EMR's Spark engine also includes the OpenLineage libraries, with minimum versions: EMR Serverless 7.5+, EMR on EC2 7.11+, and EMR on EKS 7.12+. You pass a small set of Spark parameters that point the OpenLineage transport at your domain, and as with Glue, jobs using fine-grained permission mode aren't supported. See Automate lineage capture from tools for the parameters, and Capture data lineage of Amazon EMR Spark jobs into Amazon SageMaker Unified Studio for a walkthrough.
Redshift. On the Redshift data source connection, tick Import data lineage. SageMaker Catalog then pulls from Redshift system tables daily to derive lineage. No code changes are needed. See Automate lineage capture from data connections .
Tip: write down coverage before you build anything else, for example "62% of the tables feeding report X have runtime lineage." That number is your baseline, and the gap is the backlog for detectors B–D.
Step 2: Mainframe with AWS Transform
For COBOL/JCL estates, don't build a COBOL parser. AWS Transform for mainframe already analyses the codebase. It builds dependency graphs across programs, maps which programs read, write, update, or delete each Db2 table and VSAM file, traces data flow across programs and JCL jobs, and produces a field-level data dictionary from copybooks.
Your job is a small adapter: a Lambda that converts Transform's program↔data-store relationships into OpenLineage
RunEvent s. Each program becomes a job; each Db2 table or VSAM file becomes a dataset with a stable namespace such as db2://PRODDB2/SCHEMA . Tag these edges detector=aws-transform so the scorer can weight them.
Step 3: The code-parsing agent (detector C)
This is where most of the value is, and most of the risk. The target is everything runtime capture can't see: legacy SQL scripts, stored procedures, vendor-ETL exports, Airflow DAGs, PySpark that uses DynamicFrames.
Parse deterministically first, use the LLM only as a fallback
An LLM is the wrong first tool for SQL lineage. It costs more, it isn't deterministic, and it is harder to defend to an auditor. A good open-source parser like sqlglot resolves column-level lineage for most well-formed SQL. Send a file to Amazon Bedrock only when:
- the parser fails (vendor dialect, procedural T-SQL/PL/SQL, dynamic SQL), or
- the parser succeeds but can't resolve a column (
SELECT *, unknown schema), or
- you want a plain-language transform summary. This is where the LLM is better than a parser, and reviewers find it very useful.
Path | Typical share of files* | Cost | Determinism | Audit story |
|---|---|---|---|---|
sqlglot only | majority | ~zero | Full | "Parsed by sqlglot vX.Y" |
sqlglot + LLM summary | some | Low | Edges deterministic, summary not | Edges defensible, summary advisory |
LLM only | remainder | Medium | Low | Must go to review unless corroborated |
*Measure this on your own estate during the pilot. The split varies a lot between a dbt-era warehouse and a 1990s stored-procedure estate.
Fan out with Step Functions Distributed Map
The orchestration is simple: an S3 inventory of the code repository feeds a Distributed Map state, and each item invokes a parse Lambda. Distributed Map runs up to thousands of child executions in parallel, so 2,000 COBOL programs or 100 legacy scripts are a single run, not a project plan.
The parse Lambda
The following sketch shows the core of the parse Lambda. It forces structured output with Bedrock's Converse API and a single required tool. That gives you a schema-validated JSON object instead of free text you then have to parse.
Plain text
import json, os, uuid, datetimeimport boto3import sqlglotfrom sqlglot import expfrom sqlglot.lineage import lineage as sg_lineages3 = boto3.client("s3")bedrock = boto3.client("bedrock-runtime")datazone = boto3.client("datazone")DOMAIN_ID = os.environ["DATAZONE_DOMAIN_ID"]MODEL_ID = os.environ["LINEAGE_MODEL_ID"] # e.g. a Claude inference profile ARNCONFIDENCE_FLOOR = float(os.environ.get("CONFIDENCE_FLOOR", "0.85"))MAX_EVENT_BYTES = 290_000 # stay under the 300 KB lineage event limitEVIDENCE_FACET = "myorg_lineageEvidence" # custom facets are prefixed with your producer name# OpenLineage transformation subtypes, grouped by typeDIRECT_SUBTYPES = {"IDENTITY", "TRANSFORMATION", "AGGREGATION"}INDIRECT_SUBTYPES = {"JOIN", "GROUP_BY", "FILTER", "SORT", "WINDOW", "CONDITIONAL"}LINEAGE_TOOL = {"toolSpec": {"name": "emit_lineage","description": "Emit column-level lineage for the target table defined by this code.","inputSchema": {"json": {"type": "object","required": ["target_table", "columns"],"properties": {"target_table": {"type": "string"},"columns": {"type": "array", "minItems": 1, "items": {"type": "object","required": ["target_column", "sources", "subtype","summary", "confidence", "evidence_lines"],"properties": {"target_column": {"type": "string"},"sources": {"type": "array", "items": {"type": "object","required": ["table", "column"],"properties": {"table": {"type": "string"},"column": {"type": "string"}}}},"subtype": {"type": "string","enum": sorted(DIRECT_SUBTYPES | INDIRECT_SUBTYPES) + ["UNKNOWN"]},"summary": {"type": "string"},"confidence": {"type": "number", "minimum": 0, "maximum": 1},"evidence_lines": {"type": "array", "items": {"type": "integer"}}}}}}}}}}SYSTEM = [{"text": ("You are a data lineage analyst. Derive column-level lineage ONLY from the code ""provided. Never invent tables or columns that do not appear in the code. If a ""source cannot be determined, use subtype UNKNOWN and confidence below 0.5. ""Cite the line numbers that justify each mapping in evidence_lines.")}]def table_name(t: exp.Table) -> str:"""Fully qualified table name without its alias, e.g. 'db.schema.orders'."""return ".".join(p for p in (t.catalog, t.db, t.name) if p)def infer_subtype(expression: exp.Expression) -> str:"""Classify a projected expression into an OpenLineage subtype."""e = expression.unalias() if isinstance(expression, exp.Alias) else expressionif isinstance(e, exp.Column):return "IDENTITY"if e.find(exp.AggFunc):return "AGGREGATION"if e.find(exp.Case, exp.If):return "CONDITIONAL"return "TRANSFORMATION"def try_sqlglot(sql: str, dialect: str, target_cols: list[str], schema: dict | None):"""Deterministic path. Returns column mappings, or None if anything is unresolved."""if not target_cols:return Nonemappings = []try:for col in target_cols:node = sg_lineage(col, sql, schema=schema, dialect=dialect)sources = [{"table": table_name(leaf.source), "column": leaf.name.split(".")[-1]}for leaf in node.walk()if not leaf.downstream and isinstance(leaf.source, exp.Table)]if not sources:return Nonemappings.append({"target_column": col, "sources": sources,"subtype": infer_subtype(node.expression),"summary": "", "confidence": 0.98, "evidence_lines": [],# deterministic evidence: the exact expression the parser resolved"evidence_expr": node.expression.sql(dialect=dialect)[:500],})return mappingsexcept Exception:return Nonedef ask_bedrock(code: str, hint: str) -> dict:numbered = "\n".join(f"{i+1:>5}: {l}" for i, l in enumerate(code.splitlines()))resp = bedrock.converse(modelId=MODEL_ID,system=SYSTEM,messages=[{"role": "user", "content": [{"text": f"{hint}\n\n<code>\n{numbered}\n</code>"}]}],toolConfig={"tools": [LINEAGE_TOOL],"toolChoice": {"tool": {"name": "emit_lineage"}}},inferenceConfig={"temperature": 0},)for block in resp["output"]["message"]["content"]:if "toolUse" in block:return block["toolUse"]["input"]raise ValueError("Model did not return structured lineage")def transformation(c: dict) -> dict:sub = c["subtype"]if sub in DIRECT_SUBTYPES:t = {"type": "DIRECT", "subtype": sub}elif sub in INDIRECT_SUBTYPES:t = {"type": "INDIRECT", "subtype": sub}else: # UNKNOWN: keep the edge, omit the subtype, and force review downstreamt = {"type": "INDIRECT"}if c.get("summary"):t["description"] = c["summary"][:500]return tdef to_openlineage(columns: list[dict], target_table: str, job_name: str,namespace: str, evidence: dict) -> dict:"""Build an OpenLineage RunEvent with a columnLineage facet."""fields, inputs = {}, {}for c in columns:fields[c["target_column"]] = {"inputFields": [{"namespace": namespace, "name": s["table"], "field": s["column"],"transformations": [transformation(c)]} for s in c["sources"]]}for s in c["sources"]:inputs[s["table"]] = {"namespace": namespace, "name": s["table"]}producer = f"https://example.com/lineage-detectors/{evidence['detector']}"return {"eventType": "COMPLETE","eventTime": datetime.datetime.now(datetime.timezone.utc).isoformat(),"producer": producer,"schemaURL": "https://openlineage.io/spec/2-0-2/OpenLineage.json#/definitions/RunEvent","run": {"runId": str(uuid.uuid4()),"facets": {EVIDENCE_FACET: {"_producer": producer,"_schemaURL": "https://example.com/schemas/evidence.json",**evidence}}},"job": {"namespace": namespace, "name": job_name},"inputs": list(inputs.values()),"outputs": [{"namespace": namespace, "name": target_table, "facets": {"columnLineage": {"_producer": producer,"_schemaURL": "https://openlineage.io/spec/facets/1-2-0/ColumnLineageDatasetFacet.json","fields": fields}}}],}def handler(event, _ctx):"""Invoked per file by Distributed Map. Returns scored results; publishing happensdownstream so that low-confidence results can be routed to review first."""code = s3.get_object(Bucket=event["bucket"], Key=event["key"])["Body"].read().decode()target_table = event.get("target_table")mappings = Noneif target_table: # the deterministic path needs to know which table it is resolvingmappings = try_sqlglot(code, event.get("dialect", "tsql"),event.get("target_columns", []), event.get("schema"))if mappings:columns, detector = mappings, "sqlglot"evidence_meta = {"parser_version": sqlglot.__version__}else:result = ask_bedrock(code, f"Dialect hint: {event.get('dialect', 'unknown')}")columns, detector = result.get("columns") or [], "bedrock-claude"target_table = target_table or result.get("target_table")evidence_meta = {"model_id": MODEL_ID}if not columns or not target_table:return {"events": [], "detector": detector, "min_confidence": 0.0,"needs_review": True, "reason": "no lineage extracted", "key": event["key"]}min_conf = min(c["confidence"] for c in columns)has_unknown = any(c["subtype"] == "UNKNOWN" for c in columns)evidence = {"detector": detector, "source_uri": f"s3://{event['bucket']}/{event['key']}","min_confidence": min_conf, **evidence_meta,"columns": {c["target_column"]: {"lines": c["evidence_lines"],"expr": c.get("evidence_expr", "")}for c in columns}}events = split_to_fit(columns, target_table, event["key"], event["namespace"], evidence)return {"events": events, "detector": detector, "min_confidence": min_conf,"needs_review": detector != "sqlglot" and (min_conf < CONFIDENCE_FLOOR or has_unknown)}def split_to_fit(columns, target_table, job_name, namespace, evidence):"""Split wide tables into several events so each stays under the size limit."""ev = to_openlineage(columns, target_table, job_name, namespace, evidence)if len(json.dumps(ev).encode("utf-8")) <= MAX_EVENT_BYTES or len(columns) == 1:return [ev]mid = len(columns) // 2trim = lambda cols: {**evidence, "columns": {c["target_column"]: evidence["columns"][c["target_column"]]for c in cols}}return (split_to_fit(columns[:mid], target_table, job_name, namespace, trim(columns[:mid])) +split_to_fit(columns[mid:], target_table, job_name, namespace, trim(columns[mid:])))def publish(ol_event: dict):"""Called only for auto-approved or human-approved edges."""payload = json.dumps(ol_event).encode("utf-8")if len(payload) > MAX_EVENT_BYTES:raise ValueError(f"Lineage event is {len(payload)} bytes; split it before publishing")datazone.post_lineage_event(domainIdentifier=DOMAIN_ID, event=payload,clientToken=ol_event["run"]["runId"])
Note: this is a starting skeleton, not production code. Add retries, chunking for very large files, cross-file schema context, and IAM least privilege. The publishing role needsdatazone:PostLineageEventon the domain; AWS KMS permissions too if the domain uses a customer managed key.
Three design choices here matter more than the code itself:
- Evidence on every edge. LLM-derived edges cite numbered
evidence_lines; deterministic edges record the parser version and the exact expression sqlglot resolved. Every edge points to what justifies it. Reviewers confirm the cited lines, and auditors get a citation for every edge.
-
temperature: 0and a forced tool. You want the same answer on re-runs and a schema-valid object every time.
- Publishing is separate from parsing. The parse step never writes to the system of record. Only the review workflow does, and
split_to_fitkeeps each event under the 300 KB limit.
Step 4: The similarity detector (detector D)
Some lineage has no code at all: a CSV someone exports every month, a table copied by a vendor tool, a column renamed in a migration. To find these you compare the data itself.
For each column, build a compact profile from AWS Glue Data Quality statistics (type, null %, distinct count, min/max, top values), plus the column name and catalog description. Embed the profile as text with an Amazon Bedrock embedding model, store the vectors in Amazon S3 Vectors, and run k-nearest-neighbour search across systems.
Treat every similarity match as a suggestion.customer_dobin two systems will look alike whether or not one feeds the other. These edges always go to review, and they're labelledCANDIDATE, notDERIVED, in the graph.
Step 5: Merge, score, review
Every detector emits OpenLineage, so merging becomes an identity problem rather than a format problem. A normaliser Lambda canonicalises namespaces and dataset names. It uses the
sourceIdentifier attribute so that published nodes link to the assets already in SageMaker Catalog (see The importance of the sourceIdentifier attribute to lineage nodes ). For AWS resources, sourceIdentifier is an ARN, such as arn:aws:glue:<region>:<account-id>:table/<database>/<table-name> , so the normaliser should map legacy names to these ARNs wherever a catalogued asset exists.).A simple, explainable confidence policy
This is the routing policy from Part 1 , expressed as detector rules. Don't over-engineer the scorer. Auditors and reviewers need to understand it.
Evidence for an edge | Route |
|---|---|
Runtime capture (A) or AWS Transform (B) | Auto-approve |
sqlglot (C, deterministic) | Auto-approve |
LLM (C) and corroborated by A/B or by sqlglot on a sibling file | Auto-approve |
LLM (C) alone, confidence ≥ floor, low-criticality domain | Auto-approve, sampled 10% for review |
LLM (C) alone, below floor or feeds a regulated report | Human review |
Similarity (D) | Human review (always) |
Detectors disagree | Human review (shown as a conflict) |
The review workflow
Step Functions'
.waitForTaskToken integration pauses the workflow for each edge (or, better, each target table) until a reviewer acts. The task token goes onto the review queue. The review UI, whether Amazon A2I or a small internal app, shows:- the proposed edge and its transformation summary,
- the evidence: highlighted code lines, profile comparison, or the conflicting edge,
- Approve / Edit / Reject buttons that call
SendTaskSuccesswith the decision.
On approval, the workflow calls
publish() . Every decision is written to DynamoDB, and the evidence bundle to S3 with Object Lock: detector, model ID, prompt version, reviewer identity, timestamp. This record is what makes the lineage "audit-grade" rather than just plausible.Rejections are useful training data. Every rejected or edited LLM edge becomes a labelled example. Track precision and recall per detector and per code dialect, and use the failures to improve prompts. Without this you have no evidence when someone asks how accurate the system is.
Step 6: One graph, three audiences
Compliance and audit use the lineage view in SageMaker Unified Studio to trace a report column back to source, and an Amazon Quick Sight dashboard over the DynamoDB decisions table for coverage and review metrics.
Migration teams query source-to-target mappings and downstream impact before each cut-over wave. If you need deep multi-hop traversals, such as "everything within six hops of this Db2 table", sync the validated graph one way into Amazon Neptune and use openCypher. The Glue + Neptune + Spline lineage pattern is a good reference for the graph model. Keep SageMaker Catalog as the single system of record; Neptune is a read-optimised projection.
AI agents are where lineage moves from being a cost to being an enabler. Expose a small set of lineage tools through Amazon Bedrock AgentCore Gateway as MCP tools:
Tool | Backed by | Agent uses it to… |
|---|---|---|
get_upstream(dataset, column) | GetLineageNode traversal | Check where a metric really comes from before using it |
is_validated(dataset) | DynamoDB decisions table | Refuse to query datasets with unreviewed lineage |
find_authoritative(business_term) | Catalog glossary + lineage | Choose the golden source, not a stale copy |
Then use AgentCore Policy (Cedar) to make validation a hard rule, not a suggestion. For example, an NL2SQL agent's query tool is only permitted against datasets whose lineage status is
VALIDATED . This is the same deterministic, policy-enforced pattern used for governed agentic data access: the agent can propose which data to use, and the policy decides.
Well-Architected checklist
Pillar | What to do |
|---|---|
Security | Scope PostLineageEvent to the publish role only; reader roles get GetLineageNode / ListLineageNodeHistory . Use an AWS Key Management Service (AWS KMS) customer managed key on the domain. VPC endpoints for DataZone and Bedrock so code never crosses the internet. Bedrock doesn't use your prompts or code to train models. |
Reliability | Distributed Map with per-item retries and a failure threshold; failed items go to an Amazon Simple Queue Service (Amazon SQS) DLQ for re-runs. Use clientToken on publish so retries are idempotent. |
Operational excellence | Everything in the AWS Cloud Development Kit (AWS CDK). Amazon EventBridge rules on code-repo commits and new Glue/Redshift jobs trigger incremental re-parsing, which handles drift. Amazon CloudWatch alarms when a runtime event contradicts a validated edge. |
Cost optimisation | Deterministic parse first. Use Bedrock batch inference for the initial backlog; on-demand only for incremental changes. Profile only the columns in the target domain, not the whole estate. |
Performance efficiency | Chunk very large files by procedure or paragraph. Add Neptune only when traversal depth needs it. |
Sustainability | Incremental re-parsing only. Don't re-analyse unchanged code. |
Cleanup
To avoid ongoing charges, delete the resources you created:
- Stop or delete the Step Functions state machine and any running executions, then delete the parse and normaliser Lambda functions.
- Delete the DynamoDB decisions table and the S3 Vectors index used by the similarity detector.
- Delete the S3 buckets for code inventory and evidence. Buckets with S3 Object Lock in compliance mode can't be emptied until the retention period ends, so use a short retention period for pilots.
- If you created one, delete the Neptune cluster.
- In SageMaker Unified Studio, turn off Import data lineage on your Redshift connections, and delete test lineage nodes if you don't want them kept.
- Remove the AgentCore Gateway targets and policies you created for the lineage tools.
Conclusion
In this post, you built AI-assisted, human-validated lineage on AWS. Native capture in SageMaker Catalog and AWS Transform provides the high-confidence baseline. A Step Functions and Amazon Bedrock code-parsing agent fills the gaps, with every edge citing its evidence. A confidence policy and a review workflow decide what becomes fact, and AgentCore Policy lets agents use only validated data.
Don't deploy this across the whole estate on day one. Follow the six-week pilot from Part 1 : one regulatory report or migration wave, native capture first, detector C on the code behind it, expert review against a manual sample, then one consumer connected. The most valuable result is a precision and recall figure for your own code, because it turns lineage from a vendor claim into a control you can defend.
Try it on one report and share your precision numbers in the comments. I'd like to know which code dialects gave the parser and the model the most trouble.
References
- QuantumBlack, AI by McKinsey: Solving the data lineage problem that's holding enterprise AI back
Opinions are my own. Code samples are illustrative and provided as-is; review and test before production use.
- Part 1 of this series: AI proposes, people decide
Opinions are my own. Code samples are illustrative and provided as-is; review and test before production use.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article