Releases: flyersworder/agentic-data-contracts
Release list
v0.20.0 — align SDK adapter session-limit enforcement
Changed
create_sdk_mcp_servernow auto-applies session-limit enforcement to all 9 tools by default, matching the v0.19.0 behavior ofcreate_langchain_tools. Pre-v0.20.0, onlyrun_queryself-checkedContractSessionlimits — lookup tools (describe_table,list_metrics, etc.) bypassed. The two adapters now behave identically: a single contract YAML enforces the same way under SDK and LangChain.- Practical effect:
max_duration_secondsnow measures wall-clock from the first tool call (any tool), not just from the firstrun_query. For most contracts this is invisible — lookups complete in milliseconds. The narrow population that sees a behavior change: agents with tightmax_duration_secondsAND lookup-heavy prompts that browse extensively before querying. The new behavior matches the YAML's documented intent ("the agent has N seconds total"), and closes the runaway-loop gap where an agent stuck on lookup tools previously bypassed the duration cap. - Escape hatch: pass
apply_middleware=Falsetocreate_sdk_mcp_serverto restore pre-0.20.0 behavior.
Added
- New
_wrap_with_session_check(inner, session)helper intools/sdk.py— exported as a private symbol so tests can verify the enforcement wrapper directly without going through the SDK's@tooldecorator. Mirrors the in-tool enforcement pattern intools/langchain.py:_to_structured_tool.
Fixed
- Wrapper-emitted BLOCKED envelopes now include the canonical
Remaining: {budget}suffix thatrun_query's self-emitted blocks have always carried (perfactory.py:627-628). Pre-0.20.0 the LangChain wrapper (introduced in v0.19.0) and the new SDK wrapper both omitted this suffix, so agents whose retry-planning logic depended on the suffix would lose context once they hit the wrapper layer instead ofrun_query's own block. Applies to both_wrap_with_session_check(SDK) and_to_structured_tool/ContractMiddleware._check(LangChain).
Compatibility
- Public API unchanged —
create_sdk_mcp_servergains one optional kwarg with a sensible default. Pre-builtToolDeflists, custom sessions, and all other call shapes continue to work. - 6 new tests in
tests/test_tools/test_sdk.pycover the wrapper behavior and the new kwarg.
v0.19.0 — LangChain / deepagents adapter
Added
- LangChain / deepagents integration: new optional
[langchain]extra exposescreate_langchain_tools()returning alist[BaseTool]consumable bydeepagents.create_deep_agent(tools=...)or anylangchain.agents.create_agent(tools=...). Each tool wraps the existingToolDefviaStructuredTool.from_function(args_schema=<JSON Schema dict>, response_format="content_and_artifact")— the JSON Schema reaches the agent verbatim and the original MCP envelope is preserved onToolMessage.artifact.BLOCKED —envelopes from the underlying tools (tools/factory.py,tools/middleware.py) are surfaced asToolException, which the agent loop renders asToolMessage(status="error"). ContractMiddleware(AgentMiddleware)for graph-level enforcement. Pair withcreate_langchain_tools(..., apply_middleware=False)to avoid duplicate work when the middleware is installed at the deepagents/LangGraph boundary. RunsValidator.validate(sql)+ContractSession.check_limits()and short-circuits withToolMessage(status="error").- Top-level lazy re-exports of
create_langchain_toolsandContractMiddlewarefromagentic_data_contracts. The base install (without the[langchain]extra) keeps both names defined asNone, sofrom agentic_data_contracts import …continues to work unchanged for existing Claude Agent SDK users.
Notes
- Auto-applied in-tool enforcement intentionally does not validate SQL on every call (unlike
contract_middleware); doing so would blockinspect_query's purpose of reporting violations as JSON. SQL validation is left torun_query's self-checks attools/factory.py:632-672, which still surface asToolExceptionvia the canonicalBLOCKED —prefix sniff. - Backward-compatible: zero changes to
tools/factory.py,tools/sdk.py,tools/middleware.py, or any existing top-level import. Pinned to current stable:langchain-core>=1.3.3,langchain>=1.2.17.
v0.18.0 — parse Relationship from Cube joins config
Added
- Cube schema relationship parsing:
CubeSource.get_relationships()andget_relationships_for_table()no longer return empty stubs. Each cube'sjoins:block is parsed and projected intoRelationshipinstances, completing the trifecta withYamlSource(v0.16.0) andDbtSource(v0.17.0) — every built-in semantic source now carries the samepreferred,required_filter, andrelationship_typesemantics end-to-end. from/tonormalisation: The Relationship'sfromis always the column on the cube declaring the join, regardless of which side{CUBE}appears on in the SQL. Authors who write{CUBE}.fk = {Other}.pkand authors who write{Other}.pk = {CUBE}.fkget identical Relationship instances. Thetypecarries the cardinality (many_to_one/one_to_one/one_to_many), so ahasManyjoin on cube A producesA.pk -> B.fkwithtype=one_to_many, matching howYamlSourceauthors hand-write the same pattern.- Cube enum mapping:
belongsTo/hasOne/hasMany(camelCase v1) andmany_to_one/one_to_one/one_to_many(snake_case v2 aliases) all collapse to the canonicalRelationship.typestrings via a small lookup table.meta.relationship_typeoverrides for unusual cases. meta:block convention matchesDbtSource's v0.17.0 contract:meta.preferred,meta.required_filter,meta.relationship_typeon each join entry.- Index reuse via
build_relationship_index—CubeSourceinherits the v0.16.0 preferred-first stable-sort guarantee, same asYamlSourceandDbtSource.
Documentation
- 11 new tests in
tests/test_semantic/test_cube.py(TestCubeRelationships) covering: load count with one phantom-cube reference filtered, canonical edge round-trip, non-preferred default, reversed-SQL parsing ({Other}.pk = {CUBE}.fkform),hasMany->one_to_manyalias mapping, self-FK withmeta.relationship_typeoverridinghasOne, preferred-first index ordering, referenced-side indexing, self-FK appearing once not twice, the original empty-joins fixture continuing to return[], and unresolvable cube names being silently skipped. - New fixture
tests/fixtures/sample_cube_schema_with_joins.yml— Orders cube with two role-playing joins into Users (customer_idpreferred,sales_rep_idnot), a Customers cube with ahasManyjoin into Orders, a self-referencing Employees cube, and one deliberately broken join pointing at a non-existent cube to exercise the silent-skip path. Existingsample_cube_schema.ymlleft untouched as the empty-joins baseline. - README's Semantic Sources / Cube section documents the parser convention with a
joins:example showing all threemeta:keys; architecture doc gains a paragraph mirroring the v0.17.0 dbt note.
Limitations
- Composite-key joins (
{CUBE}.tenant_id = {Other}.tenant_id AND {CUBE}.id = {Other}.parent_id) are not parsed — declare those viaYamlSourceinstead. The TODO surfaces as a "skipped silently" outcome rather than an error so existing Cube schemas don't break.
v0.17.0 — parse Relationship from dbt manifest relationships tests
Added
- dbt manifest relationship parsing:
DbtSource.get_relationships()andget_relationships_for_table()no longer return empty stubs. Eachrelationshipsschema test in the manifest (resource_type == "test",test_metadata.name == "relationships") projects into aRelationshipinstance. The owner model is resolved viaattached_node(manifest v12+); the referenced model is taken fromdepends_on.nodesminus the owner (with self-FK fallback when there's only one entry). Tests that don't carryrelationshipssemantics (not_null,unique, custom test types) are silently filtered out, as are tests whose dependencies can't be resolved to a model — manifests routinely carry tests on seeds/sources we don't track. meta:-block convention matches the existing_parse_metricspattern (meta.tier,meta.domains): three optional keys on eachrelationshipstest surface end-to-end through the same code paths that already serveYamlSource-loaded relationships:meta.preferred: bool— canonical-join hint, propagates to the index sort, prompt rendering, andlookup_relationshipsJSON.meta.required_filter: str— SQL predicate enforced byRelationshipChecker.meta.relationship_type: str— overrides the defaultmany_to_one(acceptsone_to_one,many_to_many).
- Index reuse:
DbtSourcenow builds its own_rel_indexviabuild_relationship_index, inheriting the same preferred-first stable-sort guaranteeYamlSourcegot in v0.16.0. No new helper code; the parser is the only addition.
Documentation
- 9 new tests in
tests/test_semantic/test_dbt.py(TestDbtRelationships) covering the round-trip for canonical and non-preferred edges, self-referencing FK,meta.relationship_typeoverride, preferred-first index ordering, the referenced-side appearing in the index, self-FK appearing once not twice, non-relationshipstests being filtered, and the original empty-relationships fixture continuing to return[]. - New fixture
tests/fixtures/sample_dbt_manifest_with_relationships.json— a realistic manifest v12 shape with a multi-FKordersmodel (preferredcustomer_id+ secondarysales_rep_id), a self-referencingemployees.manager_id, and decoynot_null/uniquetests to confirm filtering. Existingsample_dbt_manifest.jsonleft untouched so it stays the empty-relationships baseline. - README's Semantic Sources / dbt section documents the manifest convention with a
schema.ymlexample showing all threemeta:keys; architecture doc gains a paragraph describing the resolution logic and skip semantics.
Notes
CubeSource.get_relationships()still returns[]— Cube'sjoins:block carries a SQL expression that needs parsing to extract column references, which is a separate piece of work. The TODO atsemantic/cube.py:73is preserved.
v0.16.0 — preferred flag on Relationship for canonical join paths
Added
preferred: boolflag onRelationship: Marks the canonical join when multiple parallel join paths exist between the same pair of tables — the role-playing-dimension and multi-role-FK case (fact_sales → dim_dateonorder_date/ship_date/deliver_date;orders → usersoncustomer_id/sales_rep_id/approver_id). DefaultFalse. Surfaces as a per-edgepreferred="true"attribute in the rendered prompt and as"preferred": trueinlookup_relationshipsJSON output (omitted when false, mirroring therequired_filtershape).- Index-time stable sort:
build_relationship_indexnow stable-sorts each adjacency list with preferred edges first (edges.sort(key=lambda r: not r.preferred)). One line of code propagates the invariant to two consumers automatically:find_join_path(BFS) picks the preferred edge when alternatives exist at the same hop depth, andget_relationships_for_table(thelookup_relationshipsdirect-lookup path) returns preferred-first ordering. The flat list returned bySemanticSource.get_relationships()deliberately keeps declaration order — that list feeds the prompt renderer, which uses the per-edgepreferred="true"attribute instead of reordering. Forward-compat: a futurepreference: int | Nonerank field can be added as a non-breaking superset, treatingpreferred=Trueaspreference=0. - YAML loader threading:
YamlSourcereadspreferredfrom each relationship entry, defaulting toFalsewhen absent. Thedbtandcubeloaders are unchanged (they still return[]as TODO stubs); when those parsers are filled in,preferredwill read from their respectivemeta:blocks. growth_agentexample demonstrates the feature end-to-end:analytics.eventsgains areferrer_user_idcolumn populated only for users withacquisition_source = 'referral', creating two parallel edges intoanalytics.users. The canonical actor join (events.user_id → users.id) carriespreferred: true; the referrer join is unmarked and the description steers the agent toward it only for referral-mechanics questions. Both referral users (charlie, henry) carry their referrer FK across every event row in their history, so referral attribution is observable at event grain.
Changed
- Authoring guidance: When alternatives are semantic peers (e.g. role-playing date dimensions where
order_dateandship_dateare equally valid), authors should leave all edges unmarked and rely ondescriptionfor disambiguation. The boolean form is intentional — it captures "canonical vs secondary" without inviting authors to invent false hierarchies among genuine peers. No uniqueness validator is enforced at load time (matchesAllowedTable.preferred's lenient handling).
Documentation
- 11 new tests across three suites: 8 in
tests/test_semantic/test_relationships.py(TestPreferredRelationshipclass — default value, YAML round-trip, index sort stability,find_join_pathpreference, declaration-order preservation acrossget_relationships()vsget_relationships_for_table()); 2 intests/test_tools/test_relationship_tools.pycovering JSON shape and preferred-first ordering for both direct-lookup andjoin_pathmodes; 1 intests/test_core/test_prompt_renderers.pymirroring theAllowedTable.preferredrendering test pattern. - New fixture
tests/fixtures/relationships_preferred.yml— three parallel edges betweenanalytics.ordersandanalytics.userswith the preferred edge deliberately at position 2 of 3, decoupling preferred-first behaviour from declaration order so a no-op sort would fail the tests. - README's Table Relationships section gains a parallel-edge YAML snippet and a
preferredrow in the field reference table covering prompt rendering, direct-lookup ordering, and BFS path-finding bias. Architecture doc'sRelationshipfield listing extended with the same semantics.
v0.15.1
Fixed
preview_tablehonoured table-level access only — bypassing per-rule data-visibility gates (#20). The tool ran a synthesisedSELECT * FROM <table> LIMIT Ndirectly throughadapter.execute()after checkingallowed_table_names_for(principal), so v0.14's per-ruleblocked_columns(withallowed_principals/blocked_principalsscoping) and v0.15'srequired_filter_valuesper-principal value allowlist were silently skipped. Any caller allowed at the table level could read every column via preview, even columns that ablocked_columnsrule restricted to a whitelist — and could see every row, even when arequired_filter_valuesrule meant they should only see a value-bound subset.- Fix scope:
preview_tablenow consults the same per-rule, per-principal gates that the validator applies, classified by enforcement:blockrules refuse the preview with a structured BLOCKED message naming the rule;warn/logrules surfaceWARNINGS:/LOG:preambles before the JSON body, mirroringrun_query's convention. Query-shape rules (required_filter,no_select_star,require_limit,max_joins) remain bypassed by design — those guard user-supplied SQL inrun_query, not preview's auto-built discovery query.result_checkrules are also skipped (preview executes no result-check pipeline). - Behavioural contract: preview honours rules that gate which data an in-scope caller may see (
blocked_columns,required_filter_values); it bypasses rules that gate query shape the caller writes. The matching predicate mirrorsValidator._is_table_in_scope+_rule_applies_to_principal(validator.py:233-247) — including theprincipal_in_scopeskip semantics for unidentified callers against principal-scoped rules.
Changed
- New module-level helper
_caller_label(principal)intools.factory: collapses theprincipal if principal else "<no caller identified>"idiom shared bydescribe_tableandpreview_tableinto one place. Pure refactor; output messages are byte-identical.
Documentation
- 9 new edge-case tests for
preview_tablecovering: wildcardtable: "*"rule, omittedtable:(None) rule, unidentified caller against unscoped vs principal-scoped rules,enforcement: warnandenforcement: logpreamble surfacing,result_checkrules being correctly skipped,required_filter_valuesblocking when the principal is invalues_by_principal, and falling through when the principal is unmapped. Built via small inline contracts so the sharedprincipals_contract.ymlfixture (used by 8 other test modules) is untouched. - 3 new regression tests on the issue's exact alice / intern / bob scenarios.
v0.15.0 — per-principal value allowlist on WHERE filters
Added
- Per-principal value allowlist on WHERE filters: New
required_filter_valuesfield onQueryCheckcarries acolumnplus avalues_by_principal: dict[str, list[str | int | float]]map. The newRequiredFilterValuesCheckerwalks the WHERE clause as a boolean tree and enforces that every literal predicate value pinning the column is in the calling principal's allowlist. Composes with the existingallowed_principals/blocked_principalsrule scoping; principals not in the value map fall through (rule is a no-op for them — pair withallowed_principalsfor hard deny on unknown callers). This closes the value-bound counterpart torequired_filter: column-presence is no longer enough, the values must also match. - Two-layer static analysis: A literal-set guard collects every literal value referenced against the target column anywhere in the AST and rejects values outside the allowlist regardless of AND/OR structure (catches cross-alias smuggling like
t1.account_id = 123 AND t2.account_id = 999and contradictions). A coverage analysis (_Coveragestate machine) then enforces that the column is actually pinned at all (catchesaccount_id = 123 OR amount > 0). Non-literal predicates on the target column — subqueries, function calls, BETWEEN, range comparisons (<,>,!=,LIKE), NOT-wrapped EQ/IN — are rejected as unprovable.IS NULL/IS NOT NULLare treated as presence predicates (UNBOUND) so defensiveIS NOT NULL AND col = 123patterns pass cleanly. - Numeric form normalisation via
_canon: YAML int123, YAML float123.0, SQL literal123, SQL literal123.0, and string"123"all collapse to the same canonical key. Scientific notation (1e3/1000), integer-valued floats, and bool literals round-trip stably. - Principal-aware system-prompt rendering:
to_system_prompt(principal=...)andClaudePromptRenderer.render(principal=...)now filterrequired_filter_valuesto expose only the calling principal's allowlist. Other principals' value lists never appear in the prompt. partner_customer_scoperule in therevenue_agentexample demonstrates the feature end-to-end with two external partners scoped to different account allowlists. Deliberately not table-scoped — per-value rules apply globally so a partner can't bypass them via a join to a sibling table that exposes the same column.
Changed
- Breaking:
PromptRenderer.render()protocol signature gained a third optional argumentprincipal: str | None = None. Custom renderers written against the prior 2-arg signature (def render(self, contract, semantic_source=None)) will raiseTypeErrorwhen called viato_system_prompt, which now always passesprincipal=as a keyword argument. Migration: addprincipal=Noneto the renderer's signature; if you don't need per-principal filtering, ignore the value. Validatorquery-checkercheck_astcalls now threadresolved_principal=as a keyword argument uniformly.RequiredFilterChecker,NoSelectStarChecker,BlockedColumnsChecker,RequireLimitChecker, andMaxJoinsCheckerall gained**_to accept and ignore the new kwarg — purely additive, no behaviour change.- Mutual-exclusion validator on
QueryCheck: A single check may not set bothrequired_filterandrequired_filter_values— they target the same column conceptually; pick one.
Documentation
- 23 new unit tests for
RequiredFilterValuesCheckercovering subset matches, smuggled values, OR-bypass, AND-narrowing, contradictions, self-join smuggling, BETWEEN/range rejection, NOT-wrapped predicates,IS NULL/IS NOT NULL, numeric and string normalisation, and qualified column refs. - 10 integration tests covering the full validator wiring (resolved-principal threading, ContextVar-switched late binding for the Webex pattern, per-principal value gating).
v0.14.0 — per-rule principal access control
Added
- Per-rule principal access control: New optional
allowed_principals/blocked_principalsfields onSemanticRule(mutually exclusive at load time) gate individual rules by caller identity, mirroring the v0.13.0AllowedTablesemantics. A rule whose principal scope excludes the current caller is skipped at validate-time. This generalises across every rule kind —blocked_columns,required_filter,no_select_star,max_joins, andresult_check— letting contracts express things like "Alice may not select `ssn` from `pii.users`, but Bob may" directly in YAML, without having to split tables into per-principal views. - `principal_in_scope()` helper in `agentic_data_contracts.core.principal`: Single source of truth for the allow/block-list policy used by both `DataContract.allowed_table_names_for` and per-rule scoping. Encapsulates the two-layer empty-string invariant so unauthenticated callers (`None` or `""`) fail closed against any restricted resource.
- `ops_agent` example demonstrates per-rule principal gating end-to-end: Adds a block-level rule that lets `compliance@co.com` select PII columns from `sre.incidents` while every other identified caller is denied — composes with the existing per-table gate on `sre.deploys` to show table-level and rule-level controls side by side.
Changed
- `Validator` query and result rule lists are now small frozen dataclasses (`_QueryRuleEntry`, `_ResultRuleEntry`) rather than plain tuples, carrying an extra `principal_scope` snapshot. Internal change — public `Validator` API is unchanged.
- `pending_result_check_names()` documents the superset contract: When rules carry `allowed_principals` / `blocked_principals`, the actual run-set for a given caller is `<= pending`. The method intentionally does not resolve a callable principal at call time (TOCTOU avoidance); the only consumer is `run_query` telemetry.
- Resolved the v0.13.0 known limitation around rule-level scoping — see the new per-rule principal access control feature above. The system-prompt-rendering limitation is unchanged.
v0.13.0 — Per-Table Principal Access Control
Highlights
Add optional per-table allow/blocklist gates keyed on an opaque caller identity (email, Webex ID, employee number — any string). Designed for two deployment shapes:
- Chainlit app — one authenticated user per session. Pass a static string:
create_tools(contract, caller_principal="alice@co.com"). - Webex room bot — one long-lived bot instance serving multiple users. Pass a zero-arg callable reading a
contextvars.ContextVarset per incoming message:create_tools(contract, caller_principal=lambda: current_sender.get()).
Fail-closed by default: any allowed_principals or blocked_principals field on a table requires identification. Query-time gating is authoritative — denied queries never reach the database (verified by a spy-adapter integration test).
YAML
allowed_tables:
- schema: analytics
tables: [orders] # open to all
- schema: hr
tables: [salaries]
allowed_principals: [alice@co.com] # allowlist
- schema: raw
tables: [audit_log]
blocked_principals: [intern@co.com] # blocklistAPI
New keyword-only caller_principal parameter on both Validator and create_tools, accepting str | Callable[[], str | None] | None. Principal type alias and resolve_principal helper are re-exported from the package root.
Error messages
Two-tier diagnostics distinguish "not declared" from "declared but restricted to other principals (caller: 'X')" — actionable for both humans and LLMs.
Example
examples/ops_agent now demonstrates the feature end-to-end. Try:
uv run python examples/ops_agent/agent.py --caller intern@co.com "Show recent deploys"The intern is in blocked_principals on sre.deploys, so the query is denied; sre.incidents remains queryable (principal access is per-table).
Known limitation
to_system_prompt() currently renders the unscoped table list — an LLM serving a restricted caller may still be told about tables it can't query, wasting retry budget on queries that will be blocked at validation time. Query-time gating remains authoritative. Principal-aware prompt rendering is a candidate follow-up.
Bundled dependency cleanup
This release also requires ai-agent-contracts>=0.3.1, which moved litellm to an optional extra. The default lock shrinks from 108 → 78 resolved packages, dropping aiohttp (10 CVEs), openai, tiktoken, tokenizers, and friends from the core graph.
See CHANGELOG.md for details.
v0.12.0 — governance tightening
Highlights
- Security fix:
RequiredFilterCheckernow rejects tautological predicates. PreviouslyWHERE tenant_id = tenant_idsatisfied a blockingrequired_filterrule — a real bypass of the governance contract. Covers=,!=,<,<=,>,>=,LIKE,ILIKE,IS,IN,BETWEENself-reference variants. - Governance staleness detection: optional
last_reviewed: date | Nonefield onDomainandMetricImpact, plusfind_stale_reviews()andDataContract.find_stale()to flag artefacts whose review is missing or older than a threshold. - Two new example apps showcasing orthogonal governance archetypes:
examples/growth_agent/— experimentation / leading indicators / A/B-verified impactsexamples/ops_agent/— SRE reliability / PII-sensitive / real-time dashboards
Migration
- Review queries using self-referential predicates like
col = col— these are now rejected by blockingrequired_filterrules. - Adopting
last_reviewedis optional. If you add it to some entries, note that missing timestamps are reported as stale by default; filter withf.age_days is not Noneto grandfather in existing artefacts during rollout.
See CHANGELOG.md for the full release notes.