Skip to content

Releases: flyersworder/agentic-data-contracts

v0.20.0 — align SDK adapter session-limit enforcement

Choose a tag to compare

@flyersworder flyersworder released this 10 May 19:46
5ce86ae

Changed

  • create_sdk_mcp_server now auto-applies session-limit enforcement to all 9 tools by default, matching the v0.19.0 behavior of create_langchain_tools. Pre-v0.20.0, only run_query self-checked ContractSession limits — 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_seconds now measures wall-clock from the first tool call (any tool), not just from the first run_query. For most contracts this is invisible — lookups complete in milliseconds. The narrow population that sees a behavior change: agents with tight max_duration_seconds AND 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=False to create_sdk_mcp_server to restore pre-0.20.0 behavior.

Added

  • New _wrap_with_session_check(inner, session) helper in tools/sdk.py — exported as a private symbol so tests can verify the enforcement wrapper directly without going through the SDK's @tool decorator. Mirrors the in-tool enforcement pattern in tools/langchain.py:_to_structured_tool.

Fixed

  • Wrapper-emitted BLOCKED envelopes now include the canonical Remaining: {budget} suffix that run_query's self-emitted blocks have always carried (per factory.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 of run_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_server gains one optional kwarg with a sensible default. Pre-built ToolDef lists, custom sessions, and all other call shapes continue to work.
  • 6 new tests in tests/test_tools/test_sdk.py cover the wrapper behavior and the new kwarg.

v0.19.0 — LangChain / deepagents adapter

Choose a tag to compare

@flyersworder flyersworder released this 10 May 19:05
88c6664

Added

  • LangChain / deepagents integration: new optional [langchain] extra exposes create_langchain_tools() returning a list[BaseTool] consumable by deepagents.create_deep_agent(tools=...) or any langchain.agents.create_agent(tools=...). Each tool wraps the existing ToolDef via StructuredTool.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 on ToolMessage.artifact. BLOCKED — envelopes from the underlying tools (tools/factory.py, tools/middleware.py) are surfaced as ToolException, which the agent loop renders as ToolMessage(status="error").
  • ContractMiddleware(AgentMiddleware) for graph-level enforcement. Pair with create_langchain_tools(..., apply_middleware=False) to avoid duplicate work when the middleware is installed at the deepagents/LangGraph boundary. Runs Validator.validate(sql) + ContractSession.check_limits() and short-circuits with ToolMessage(status="error").
  • Top-level lazy re-exports of create_langchain_tools and ContractMiddleware from agentic_data_contracts. The base install (without the [langchain] extra) keeps both names defined as None, so from 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 block inspect_query's purpose of reporting violations as JSON. SQL validation is left to run_query's self-checks at tools/factory.py:632-672, which still surface as ToolException via the canonical BLOCKED — 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

Choose a tag to compare

@flyersworder flyersworder released this 02 May 14:46

Added

  • Cube schema relationship parsing: CubeSource.get_relationships() and get_relationships_for_table() no longer return empty stubs. Each cube's joins: block is parsed and projected into Relationship instances, completing the trifecta with YamlSource (v0.16.0) and DbtSource (v0.17.0) — every built-in semantic source now carries the same preferred, required_filter, and relationship_type semantics end-to-end.
  • from/to normalisation: The Relationship's from is 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}.pk and authors who write {Other}.pk = {CUBE}.fk get identical Relationship instances. The type carries the cardinality (many_to_one / one_to_one / one_to_many), so a hasMany join on cube A produces A.pk -> B.fk with type=one_to_many, matching how YamlSource authors hand-write the same pattern.
  • Cube enum mapping: belongsTo / hasOne / hasMany (camelCase v1) and many_to_one / one_to_one / one_to_many (snake_case v2 aliases) all collapse to the canonical Relationship.type strings via a small lookup table. meta.relationship_type overrides for unusual cases.
  • meta: block convention matches DbtSource's v0.17.0 contract: meta.preferred, meta.required_filter, meta.relationship_type on each join entry.
  • Index reuse via build_relationship_indexCubeSource inherits the v0.16.0 preferred-first stable-sort guarantee, same as YamlSource and DbtSource.

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}.fk form), hasMany -> one_to_many alias mapping, self-FK with meta.relationship_type overriding hasOne, 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_id preferred, sales_rep_id not), a Customers cube with a hasMany join into Orders, a self-referencing Employees cube, and one deliberately broken join pointing at a non-existent cube to exercise the silent-skip path. Existing sample_cube_schema.yml left untouched as the empty-joins baseline.
  • README's Semantic Sources / Cube section documents the parser convention with a joins: example showing all three meta: 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 via YamlSource instead. 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

Choose a tag to compare

@flyersworder flyersworder released this 02 May 14:37

Added

  • dbt manifest relationship parsing: DbtSource.get_relationships() and get_relationships_for_table() no longer return empty stubs. Each relationships schema test in the manifest (resource_type == "test", test_metadata.name == "relationships") projects into a Relationship instance. The owner model is resolved via attached_node (manifest v12+); the referenced model is taken from depends_on.nodes minus the owner (with self-FK fallback when there's only one entry). Tests that don't carry relationships semantics (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_metrics pattern (meta.tier, meta.domains): three optional keys on each relationships test surface end-to-end through the same code paths that already serve YamlSource-loaded relationships:
    • meta.preferred: bool — canonical-join hint, propagates to the index sort, prompt rendering, and lookup_relationships JSON.
    • meta.required_filter: str — SQL predicate enforced by RelationshipChecker.
    • meta.relationship_type: str — overrides the default many_to_one (accepts one_to_one, many_to_many).
  • Index reuse: DbtSource now builds its own _rel_index via build_relationship_index, inheriting the same preferred-first stable-sort guarantee YamlSource got 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_type override, preferred-first index ordering, the referenced-side appearing in the index, self-FK appearing once not twice, non-relationships tests 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-FK orders model (preferred customer_id + secondary sales_rep_id), a self-referencing employees.manager_id, and decoy not_null / unique tests to confirm filtering. Existing sample_dbt_manifest.json left untouched so it stays the empty-relationships baseline.
  • README's Semantic Sources / dbt section documents the manifest convention with a schema.yml example showing all three meta: keys; architecture doc gains a paragraph describing the resolution logic and skip semantics.

Notes

  • CubeSource.get_relationships() still returns [] — Cube's joins: block carries a SQL expression that needs parsing to extract column references, which is a separate piece of work. The TODO at semantic/cube.py:73 is preserved.

v0.16.0 — preferred flag on Relationship for canonical join paths

Choose a tag to compare

@flyersworder flyersworder released this 02 May 14:26

Added

  • preferred: bool flag on Relationship: 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_date on order_date / ship_date / deliver_date; orders → users on customer_id / sales_rep_id / approver_id). Default False. Surfaces as a per-edge preferred="true" attribute in the rendered prompt and as "preferred": true in lookup_relationships JSON output (omitted when false, mirroring the required_filter shape).
  • Index-time stable sort: build_relationship_index now 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, and get_relationships_for_table (the lookup_relationships direct-lookup path) returns preferred-first ordering. The flat list returned by SemanticSource.get_relationships() deliberately keeps declaration order — that list feeds the prompt renderer, which uses the per-edge preferred="true" attribute instead of reordering. Forward-compat: a future preference: int | None rank field can be added as a non-breaking superset, treating preferred=True as preference=0.
  • YAML loader threading: YamlSource reads preferred from each relationship entry, defaulting to False when absent. The dbt and cube loaders are unchanged (they still return [] as TODO stubs); when those parsers are filled in, preferred will read from their respective meta: blocks.
  • growth_agent example demonstrates the feature end-to-end: analytics.events gains a referrer_user_id column populated only for users with acquisition_source = 'referral', creating two parallel edges into analytics.users. The canonical actor join (events.user_id → users.id) carries preferred: 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_date and ship_date are equally valid), authors should leave all edges unmarked and rely on description for 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 (matches AllowedTable.preferred's lenient handling).

Documentation

  • 11 new tests across three suites: 8 in tests/test_semantic/test_relationships.py (TestPreferredRelationship class — default value, YAML round-trip, index sort stability, find_join_path preference, declaration-order preservation across get_relationships() vs get_relationships_for_table()); 2 in tests/test_tools/test_relationship_tools.py covering JSON shape and preferred-first ordering for both direct-lookup and join_path modes; 1 in tests/test_core/test_prompt_renderers.py mirroring the AllowedTable.preferred rendering test pattern.
  • New fixture tests/fixtures/relationships_preferred.yml — three parallel edges between analytics.orders and analytics.users with 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 preferred row in the field reference table covering prompt rendering, direct-lookup ordering, and BFS path-finding bias. Architecture doc's Relationship field listing extended with the same semantics.

v0.15.1

Choose a tag to compare

@flyersworder flyersworder released this 28 Apr 15:22

Fixed

  • preview_table honoured table-level access only — bypassing per-rule data-visibility gates (#20). The tool ran a synthesised SELECT * FROM <table> LIMIT N directly through adapter.execute() after checking allowed_table_names_for(principal), so v0.14's per-rule blocked_columns (with allowed_principals / blocked_principals scoping) and v0.15's required_filter_values per-principal value allowlist were silently skipped. Any caller allowed at the table level could read every column via preview, even columns that a blocked_columns rule restricted to a whitelist — and could see every row, even when a required_filter_values rule meant they should only see a value-bound subset.
  • Fix scope: preview_table now consults the same per-rule, per-principal gates that the validator applies, classified by enforcement: block rules refuse the preview with a structured BLOCKED message naming the rule; warn / log rules surface WARNINGS: / LOG: preambles before the JSON body, mirroring run_query's convention. Query-shape rules (required_filter, no_select_star, require_limit, max_joins) remain bypassed by design — those guard user-supplied SQL in run_query, not preview's auto-built discovery query. result_check rules 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 mirrors Validator._is_table_in_scope + _rule_applies_to_principal (validator.py:233-247) — including the principal_in_scope skip semantics for unidentified callers against principal-scoped rules.

Changed

  • New module-level helper _caller_label(principal) in tools.factory: collapses the principal if principal else "<no caller identified>" idiom shared by describe_table and preview_table into one place. Pure refactor; output messages are byte-identical.

Documentation

  • 9 new edge-case tests for preview_table covering: wildcard table: "*" rule, omitted table: (None) rule, unidentified caller against unscoped vs principal-scoped rules, enforcement: warn and enforcement: log preamble surfacing, result_check rules being correctly skipped, required_filter_values blocking when the principal is in values_by_principal, and falling through when the principal is unmapped. Built via small inline contracts so the shared principals_contract.yml fixture (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

Choose a tag to compare

@flyersworder flyersworder released this 28 Apr 14:55

Added

  • Per-principal value allowlist on WHERE filters: New required_filter_values field on QueryCheck carries a column plus a values_by_principal: dict[str, list[str | int | float]] map. The new RequiredFilterValuesChecker walks 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 existing allowed_principals / blocked_principals rule scoping; principals not in the value map fall through (rule is a no-op for them — pair with allowed_principals for hard deny on unknown callers). This closes the value-bound counterpart to required_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 = 999 and contradictions). A coverage analysis (_Coverage state machine) then enforces that the column is actually pinned at all (catches account_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 NULL are treated as presence predicates (UNBOUND) so defensive IS NOT NULL AND col = 123 patterns pass cleanly.
  • Numeric form normalisation via _canon: YAML int 123, YAML float 123.0, SQL literal 123, SQL literal 123.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=...) and ClaudePromptRenderer.render(principal=...) now filter required_filter_values to expose only the calling principal's allowlist. Other principals' value lists never appear in the prompt.
  • partner_customer_scope rule in the revenue_agent example 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 argument principal: str | None = None. Custom renderers written against the prior 2-arg signature (def render(self, contract, semantic_source=None)) will raise TypeError when called via to_system_prompt, which now always passes principal= as a keyword argument. Migration: add principal=None to the renderer's signature; if you don't need per-principal filtering, ignore the value.
  • Validator query-checker check_ast calls now thread resolved_principal= as a keyword argument uniformly. RequiredFilterChecker, NoSelectStarChecker, BlockedColumnsChecker, RequireLimitChecker, and MaxJoinsChecker all gained **_ to accept and ignore the new kwarg — purely additive, no behaviour change.
  • Mutual-exclusion validator on QueryCheck: A single check may not set both required_filter and required_filter_values — they target the same column conceptually; pick one.

Documentation

  • 23 new unit tests for RequiredFilterValuesChecker covering 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

Choose a tag to compare

@flyersworder flyersworder released this 25 Apr 07:33

Added

  • Per-rule principal access control: New optional allowed_principals / blocked_principals fields on SemanticRule (mutually exclusive at load time) gate individual rules by caller identity, mirroring the v0.13.0 AllowedTable semantics. 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, and result_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

Choose a tag to compare

@flyersworder flyersworder released this 24 Apr 19:30

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.ContextVar set 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]   # blocklist

API

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

Choose a tag to compare

@flyersworder flyersworder released this 18 Apr 13:20

Highlights

  • Security fix: RequiredFilterChecker now rejects tautological predicates. Previously WHERE tenant_id = tenant_id satisfied a blocking required_filter rule — a real bypass of the governance contract. Covers =, !=, <, <=, >, >=, LIKE, ILIKE, IS, IN, BETWEEN self-reference variants.
  • Governance staleness detection: optional last_reviewed: date | None field on Domain and MetricImpact, plus find_stale_reviews() and DataContract.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 impacts
    • examples/ops_agent/ — SRE reliability / PII-sensitive / real-time dashboards

Migration

  • Review queries using self-referential predicates like col = col — these are now rejected by blocking required_filter rules.
  • Adopting last_reviewed is optional. If you add it to some entries, note that missing timestamps are reported as stale by default; filter with f.age_days is not None to grandfather in existing artefacts during rollout.

See CHANGELOG.md for the full release notes.