
Data Contract Sentinel — LLM-Native Contract Enforcement on Amazon Bedrock AgentCore
Boolean data-contract tools tell you a schema change is unsafe. They don't tell you which team gets broken, what to propose instead, or how to escalate. This hands-on Strands agent — deployed on Amazon Bedrock AgentCore, the successor to Bedrock Agents — reads a YAML contract, watches an Apache Iceberg table on AWS, and negotiates schema changes with the humans proposing them: downstream citations, safer alternatives, and a hard "no" when the contract says so. Full runnable repo included.
Every data platform team has had this conversation. It's Friday afternoon. Someone in #data-eng posts:
"Hey — quick one, can I renamecustomer_idtobuyer_idin the orders table? Product wants consistency with the new checkout schema."
Six eyes light up in three different Slacks. Finance uses
customer_id in the monthly close. Growth uses it in the LTV model. Someone types "please no", someone else types "sure, one sec", nobody has time to actually trace what breaks, and by Monday the pipeline is red.Data contracts are supposed to fix this. Declare the shape of your table, its quality thresholds, its consumers, its allowed change types. Enforce it with tooling. And to be fair, that tooling exists — Soda , the Data Contract CLI , Great Expectations. They all work.
They also all produce booleans. Pass or fail. Green or red. And the moment your organization needs to negotiate a change — "yes but only if we give finance thirty days" — you're back to Slack.
This article is about the version of that tooling built with an LLM agent underneath, so it can do what a boolean can't: explain, cite, and counter-propose.
Repo: github.com/prabhacloud/data-contract-sentinel . Clone,
cdk deploy the infra, agentcore launch the agent, chat.Why this is different from every text-to-SQL demo
In my last piece I looked at the shape of an agentic data lake. When I sat down to build the code companion, the obvious follow-through was: put text-to-SQL on Athena, chat with your Iceberg tables, done.
Then I checked. AWS ships a natural-language-on-S3-Tables tutorial with Bedrock Knowledge Bases. There is a build-on-aws sample repo for exactly text-to-SQL over Athena. Amazon Quick just added Iceberg via S3 Tables as a native data source with conversational analytics. There is an S3 Tables MCP server. Solutions Library has a guidance package for the same use case. Writing another text-to-SQL agent in 2026 is like publishing a "hello world in Python" tutorial.
So I went hunting for something that hadn't been built. Data contracts jumped out. Every open-source enforcer runs checks and returns a status code. None of them talk to you. And an LLM with the right tool surface is uniquely good at exactly the thing procedural enforcers can't do: explain a violation in the language of the affected consumers, and propose an alternative that doesn't break them.
That's Sentinel. Not a check runner. A negotiator.
Why AgentCore, not Bedrock Agents
If you built a Bedrock Agent in the last eighteen months, you know the ritual: define an OpenAPI 3 schema for your action group, wire it to a Lambda that dispatches on
apiPath, hope the model picks the right endpoint. That surface is being phased into maintenance mode.The forward-looking stack on AWS is Amazon Bedrock AgentCore — a serverless runtime for agents that is:
- Framework-agnostic. Strands, LangGraph, CrewAI, LlamaIndex — bring what you like. AgentCore hosts the container.
- Model-agnostic. Claude via Bedrock is the natural default. But if a foundation model outside Bedrock fits your task better, you can still use AgentCore.
- MCP-first. Sibling services (AgentCore Gateway, Memory, Identity, Observability, Code Interpreter, Browser) all speak MCP, so tools are portable across your fleet of agents.
- Actually serverless. No provisioned agent aliases. You push a container image; AgentCore scales it.
For this project I paired AgentCore Runtime with the Strands Agents SDK — AWS's Python-first agent framework. The whole thing is under 300 lines of tool code plus a container image.
The shape of it
Six tools, one Strands agent, one AgentCore Runtime deployment, one YAML contract file.
1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────┐ ┌────────────────────────┐ ┌────────────────────┐
│ You │───▶│ AgentCore Runtime │───▶│ Tools (in agent) │
│ (chat / │ │ Strands Agent │ │ read_contract │
│ agentcore │ │ Claude Sonnet 4.5 │ │ describe_table │
│ invoke) │ │ instructions.md │ │ validate_schema │
└──────────────┘ └────────────────────────┘ │ run_quality_check │
│ assess_change │
│ list_consumers │
└─────────┬──────────┘
│
┌───────────────┴───────────────┐
▼ ▼
Glue Data Catalog Athena → Iceberg on S3The contract is a YAML file baked into the container image. The live Iceberg table lives in an S3 bucket and is catalogued in Glue. Athena is the query engine for quality checks. AgentCore hosts the agent container; Bedrock provides the Claude model.
The contract — the actual product
Everything hinges on the YAML. If it's under-specified, the agent has nothing to cite. If it's over-specified, humans stop maintaining it. Here's what I ended up with, loosely modeled on the Open Data Contract Standard :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
apiVersion: datacontract.io/v1
name: orders
owner: orders-platform-team
schema:
columns:
- name: order_id
type: string
nullable: false
- name: customer_id
type: string
nullable: false
pii: true
- name: amount_cents
type: bigint
nullable: false
- name: currency
type: string
allowed_values: [USD, EUR, GBP, JPY]
quality:
- column: order_id
check: uniqueness
threshold_pct: 100
severity: block
- column: amount_cents
check: min
value: 0
severity: block
consumers:
- team: finance
contact: finance-data@example.com
uses: [{dashboard: monthly_revenue}, {report: quarterly_close}]
columns_used: [order_id, amount_cents, currency, status, event_date]
breaking_change_notice_days: 30
- team: growth
contact: growth-ml@example.com
columns_used: [customer_id, amount_cents, created_at]
breaking_change_notice_days: 14
change_policy:
allowed: [add_column, add_allowed_value, relax_nullability]
requires_negotiation: [rename_column, narrow_type, remove_allowed_value, tighten_nullability]
blocked: [drop_column_in_use, change_partition_scheme]Three things worth calling out:
consumersis the load-bearing block. It's what lets the agent cite "this breaks growth's LTV model" instead of "this has downstream impact." Real orgs would populate it from OpenLineage; for a demo, static YAML is honest.change_policyclassifies changes into three buckets.allowed,requires_negotiation,blocked. This is what a human data-platform lead would encode as team norms. The agent enforces those norms.severity: blockon quality checks means the agent won't approve any schema change while ablock-severity check is failing. Not because the LLM decided that, but because the contract said so.
The tools — Python functions, not OpenAPI
This is where the AgentCore + Strands stack pays off. With Bedrock Agents you would write an OpenAPI 3 schema declaring every endpoint, then a Lambda that dispatches on
apiPath. With Strands, tools are just decorated Python functions:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from strands import Agent, tool
from strands.models.bedrock import BedrockModel
def read_contract(table: str) -> dict:
"""Load the YAML data contract for a table.
Call this before any question about compliance, changes, or downstream
impact. Returns the parsed contract including schema, quality rules,
SLA, consumers, and change_policy.
"""
path = CONTRACTS_DIR / f"{table}.contract.yaml"
if not path.exists():
return {"error": f"no contract on file for table {table!r}"}
return yaml.safe_load(path.read_text())The docstring becomes the tool spec the model sees. Strands introspects the type hints (
table: str, return dict) and builds the JSON Schema automatically. No separate file to keep in sync.Six of these functions make up the whole agent surface:
| Tool | What it does |
|---|---|
read_contract(table) | Parse and return the YAML contract |
describe_table(table) | Live Iceberg schema from Glue |
validate_schema(table) | Diff live schema against contract; return violations |
run_quality_check(table, column, check_type) | Run one Athena query for one check |
assess_change(table, change_type, …) | Classify a proposed change; return safer alternative |
list_consumers(table, column?) | Downstream teams; filter by column if given |
Notice what's not here. There's no
apply_change tool. The agent can never mutate a table. This is deliberate — Sentinel is an advisor, not an operator. The DDL that follows an "allowed" verdict still goes through the normal PR process.Wiring it into a Strands agent
Assembly is short enough to fit in one snippet:
1
2
3
4
5
6
7
8
9
10
11
agent = Agent(
model=BedrockModel(
model_id="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
region_name="us-east-1",
),
system_prompt=open("instructions.md").read(),
tools=[
read_contract, describe_table, validate_schema,
run_quality_check, assess_change, list_consumers,
],
)That's the whole agent. No orchestration DSL, no state machine, no graph. Strands lets Claude call tools until it has enough to answer, then returns.
Exposing it via AgentCore
To make the agent serverlessly hostable, wrap it with the AgentCore Runtime SDK:
1
2
3
4
5
6
7
8
9
10
from bedrock_agentcore.runtime import BedrockAgentCoreApp
app = BedrockAgentCoreApp()
def invoke(payload: dict) -> dict:
return {"result": str(agent(payload["prompt"]))}
if __name__ == "__main__":
app.run()Locally,
python app.py starts an HTTP server on :8080 that speaks the AgentCore protocol. Deployed, AgentCore hosts the same container behind a managed endpoint.The deploy step is two commands:
1
2
agentcore configure -e app.py --execution-role $RUNTIME_ROLE_ARN
agentcore launchconfigure writes a .bedrock_agentcore.yaml. launch builds an ARM64 container, pushes it to ECR, and deploys the runtime. The runtime scales to zero when idle — you pay for tokens plus tiny per-invocation execution.Compare that with the old Bedrock Agent path:
CfnAgent + CfnAgentAlias + OpenAPI schema + Lambda resource policy + auto_prepare=True + Prepare API waits + alias promotion. Half a day of CloudFormation ceremony. AgentCore replaces most of it with one container push.The interesting tool: assess_change
Everything else is retrieval.
assess_change is where the pattern shows up:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def assess_change(
table: str, change_type: str, column: str = "",
new_name: str = "", new_type: str = "", value: str = "",
) -> dict:
contract = read_contract(table)
policy = contract.get("change_policy", {})
consumers = _consumers_for_column(contract, column) if column else []
if change_type == "drop_column":
if consumers:
return {
"verdict": "blocked",
"clause": "change_policy.blocked: drop_column_in_use",
"reason": f"Column {column!r} is used by {len(consumers)} consumer(s).",
"consumers_affected": consumers,
"alternative": "Do not drop. Publish notice, migrate consumers, then contract PR, then drop.",
}
# ...That's not doing anything the model couldn't do itself with the raw contract. So why bother?
Reproducibility. When the answer to "is this change allowed?" is a Python function, two runs of the same question give the same answer. When it's the model reasoning, they don't. For a policy tool, that matters more than any other property. The LLM's job is to communicate the verdict; the Python function's job is to decide it. Different skills.
The system prompt — where negotiation gets its voice
The tools give you facts.
instructions.md gives the stance. Sentinel's is short but pointed:You are the Data Contract Sentinel — a senior data platform engineer whose job is to hold the line on data contracts for Apache Iceberg tables while remaining useful to the humans proposing changes.You are not a boolean check runner. Boolean tools already exist. Your value is: you explain, negotiate, and cite.
Then the rules:
- Lead with the verdict. Then reasoning.
- Cite the contract clause (
quality[0],change_policy.blocked, …). - Propose alternatives, don't just refuse.
- Name consumers, not "downstream impact."
- Never approve a
blockedchange. Escalation is a contract-amendment PR. - Numbers must come from tools. No estimation from memory.
Rule 6 is the one that makes an LLM safe to point at contract enforcement. Rule 5 is the one that makes it useful — a soft "maybe" from an agent would neuter the whole tool.
What a session looks like
Deploy the repo, seed the demo Iceberg table (
examples/setup.sql — the seed data has deliberate violations baked in), and invoke the agent:1
agentcore invoke '{"prompt": "Show current compliance for the orders contract."}'Sentinel:
Verdict: not compliant. Contract clauses violated:*quality[1](amount_centsmin ≥ 0): 1 row at −100 cents. RowORD-0011.*quality[2](currencyvalues_in [USD, EUR, GBP, JPY]): 1 row withAUD. RowORD-0012.*quality[3](customer_idnull_rate_max 0%): 1 null. RowORD-0013.
Schema matches. Partitioning matches.
You: I want to rename
customer_id to buyer_id. Is that safe?Sentinel:
requires_negotiation. Clausechange_policy.requires_negotiation: rename_column.
Consumers usingcustomer_id: growth (ltv_v3model,churn_features_dailypipeline) — 14-day notice required.
Safer alternative: addbuyer_id; dual-write both columns for one release; announce deprecation ofcustomer_idwith ≥14-day notice; growth migrates; then dropcustomer_idvia a follow-up contract PR.
You: Just drop
currency. It's my table.Sentinel:
Still blocked. Clausechange_policy.blocked: drop_column_in_use. Columncurrencyis used by finance (monthly_revenue,quarterly_close) — 30-day notice required.
The override path is a contract-amendment PR with sign-off from finance (finance-data@example.com). I can draft the deprecation notice if you'd like.
The last one is the tell that this is worth building. A boolean tool would return
false and hang up. A helpful assistant would cave. An LLM with a stance can do neither — it explains the escalation path and keeps refusing. That's rare in AI tooling and it's exactly what a contract needs.Deploying it
The repo splits into two layers. Infrastructure (S3, Glue database, Athena workgroup, the IAM role AgentCore assumes) is one CDK stack. The agent itself is deployed by the
agentcore CLI.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
git clone https://github.com/prabhacloud/data-contract-sentinel
cd data-contract-sentinel
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# 1. Infra
cd infra/cdk && cdk bootstrap && cdk deploy
# Note the RuntimeRoleArn and TableBucket outputs.
# 2. Seed the demo Iceberg table
# Open the Athena console → workgroup sentinel_demo_wg
# Run examples/setup.sql (substitute <TABLE_BUCKET>)
# 3. Agent
cd ../..
pip install bedrock-agentcore-starter-toolkit
agentcore configure -e app.py --execution-role $RUNTIME_ROLE_ARN
agentcore launch
# 4. Chat
agentcore invoke '{"prompt": "Show current compliance for the orders contract."}'You'll need Bedrock model access for Claude Sonnet 4.5 in your region. The agent defaults to the
us. inference profile; override with the MODEL_ID env var if you're in EU/APAC.Where the pattern goes next
Contracts are the demo. The pattern is broader.
The interesting property of this design is that the "policy" and the "voice" are decoupled. The
change_policy YAML block is a data structure any deterministic tool could read. The agent's job is to communicate the outcome. That decoupling means:- Same pattern, different domain. Point the same agent scaffold at an OpenAPI spec (with breaking-change classification) and you get an API contract Sentinel. Point it at Terraform modules and you get an infrastructure change reviewer.
- The contract stays in Git. No web console, no drift between the "source of truth" and what the enforcer sees. Reviewers can diff a PR to the contract like any other code.
- Consumers can be lineage-derived later. For a demo,
consumers:is hand-maintained. In production, replace it with an OpenLineage query; the tool interface doesn't change.
Extensions worth building — each unlocks another AgentCore service
I stopped short of these to keep the repo focused, but each is a natural next step and each maps to a different AgentCore primitive:
- PR-check integration. A GitHub Action that calls
agentcore invokeon any PR touching a contract or a DDL file and posts the verdict as a review comment. - AgentCore Memory. Give Sentinel long-term memory for team-specific negotiation history — "the last time we shortened the notice window for finance, they escalated." Boolean tools cannot do this; memory-augmented agents can.
- AgentCore Gateway. Publish the six tools as an MCP server so other agents (including AWS's own DevOps Agent) can consume them.
- AgentCore Observability. Wire traces to CloudWatch to audit every verdict — who asked what, which tools were called, which contract clause was cited.
- AgentCore Code Interpreter. For quality checks Athena can't easily express (custom Python UDFs, statistical tests), let the agent execute Python in a sandbox.
Closing note
The most useful thing I learned building this: agents beat scripts when the answer isn't a yes/no. Contract compliance is a yes/no. Contract negotiation isn't. Any place your organization does policy work over Slack — data contracts, API changes, deployment approvals, capacity requests — is a place where an LLM with tools and a stance beats a linter with a config file.
And on AWS in 2026, the right platform for those agents is AgentCore. Bedrock Agents got us here; AgentCore gets us further, without the OpenAPI ritual and with a real primitive for every stage of the agent lifecycle.
If you build something on top of this repo, I'd love to see it. Especially the domain adaptations — API contracts, module contracts, feature-flag policies. Same shape, different vocabulary.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article