Skip to content

Releases: flyersworder/agentic-data-contracts

v0.18.0 — parse Relationship from Cube joins config

02 May 14:46

Choose a tag to compare

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

02 May 14:37

Choose a tag to compare

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

02 May 14:26

Choose a tag to compare

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

28 Apr 15:22

Choose a tag to compare

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

28 Apr 14:55

Choose a tag to compare

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

25 Apr 07:33

Choose a tag to compare

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

24 Apr 19:30

Choose a tag to compare

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

18 Apr 13:20

Choose a tag to compare

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.

v0.11.0 — Tool surface consolidation (13 → 9)

17 Apr 17:59
0b8bf1f

Choose a tag to compare

Breaking

  • Tool surface consolidated from 13 to 9 tools: Five tools dropped and two merged into one. The full contract is already injected into the system prompt by ClaudePromptRenderer, so the dropped tools were redundant from an analytics-agent perspective.
  • list_schemas removed: The allowed-schemas set is implicit in the allowed-tables list that the prompt renderer already injects.
  • list_tables removed: The prompt renderer already injects the full allowed-tables list. Per-table column details remain available via describe_table.
  • get_contract_info removed: Contract name, allowed tables, rules, and limits are all in the prompt. The one dynamic field the tool exposed — remaining session budget — is now embedded in every run_query response under session.remaining.
  • validate_query + query_cost_estimate merged into inspect_query: Both tools wrapped the same underlying Validator.validate() call (which internally runs Layer 1 + EXPLAIN). The merge removes a "which tool do I call?" decision. Response is structured JSON with valid, violations, warnings, log_messages, schema_valid, explain_errors, pending_result_checks, and — when an adapter is configured — estimated_cost_usd and estimated_rows.

Changed

  • run_query response: Success responses now include a session.remaining block mirroring ContractSession.remaining() (elapsed seconds, retries remaining, token budget remaining, cost remaining). Blocked responses append a one-line Remaining: {...} suffix with the same data.
  • ValidationResult dataclass: Gains three additive fields — estimated_rows: int | None, schema_valid: bool = True, and explain_errors: list[str] = []. Populated in Validator.validate() when an ExplainAdapter is configured. Defaults are safe for existing callers.

Migration

  • Replace validate_query(sql) calls with inspect_query(sql). The response is JSON rather than a status string; parse valid, violations, and warnings. Cost and row estimates live under the same response.
  • Replace query_cost_estimate(sql) calls with inspect_query(sql). Cost and row fields are now nested alongside validation fields.
  • If an agent previously called get_contract_info, read remaining budget from run_query responses (data["session"]["remaining"]) instead. Static contract metadata is already in the system prompt.
  • list_schemas and list_tables have no replacements — the prompt already contains this information.

v0.10.0 — Metric Taxonomy and Impact Graph

17 Apr 14:37

Choose a tag to compare

What's New

Metric taxonomy and impact graph — agents can now reason about metric roles (north-star vs team KPI, leading vs lagging) and walk a causal graph between metrics to ground recommendations in evidence. When asked "why did revenue drop?", an agent can follow upstream edges like active_customers → total_revenue (positive, verified): A/B test exp-042, +3.2% lift rather than blindly requerying revenue.

Added

  • Metric role metadata on MetricDefinition: optional domains / tier (north_star / department_kpi / team_kpi) / indicator_kind (leading / lagging). All defaulted — existing contracts parse unchanged.
  • MetricImpact dataclass — directed, annotated edges with direction, confidence (verified / correlated / hypothesized), free-text evidence the agent can cite verbatim. Declared via a top-level metric_impacts: block in the semantic YAML.
  • trace_metric_impacts tool (13th tool) — BFS through the impact graph. direction="upstream" returns drivers (root-cause analysis); direction="downstream" returns affected metrics (action-impact reasoning). max_depth clamped to [1, 10].
  • build_metric_impact_index() and walk_metric_impacts() helpers in semantic.base, mirroring the existing build_relationship_index / find_join_path pattern. Dual-keyed index, cycle-safe BFS.
  • get_metric_impacts() on SemanticSourceYamlSource parses from YAML; DbtSource / CubeSource return [] (impacts live in the contract YAML regardless of where metrics come from — dbt and Cube have no native causal-graph concept).
  • meta.tier / meta.indicator_kind / meta.domains read from each metric's meta dict in DbtSource and CubeSource. String→list coercion for tier / domains is consistent across YAML, dbt, and Cube.
  • Factory validation warnings for unknown metric references in metric_impacts, mirroring the existing domain-reference validation.
  • Public API surface: MetricDefinition, MetricImpact, Relationship, SemanticSource now re-exported from the top-level agentic_data_contracts package.

Changed

  • Tool count: 13 tools (was 12).
  • lookup_metric response enriched with domains, tier, indicator_kind, and citation-ready impacts / impacted_by edges (e.g. "positive impact on total_revenue (verified): A/B test exp-042, +3.2% lift"). Optional fields omitted when empty.
  • list_metrics filters: optional tier and indicator_kind alongside the existing domain filter. Entries include tier and indicator_kind when set.
  • list_metrics domain semantics: filtering now unions contract-side Domain.metrics with self-declared metric.domains. Metrics that self-declare a domain are discoverable via the filter even if the contract's Domain.metrics list doesn't include them.
  • Example (examples/revenue_agent/) demonstrates the new fields, an impact edge, and a trace_metric_impacts step in the demo.

Breaking

  • SemanticSource protocol extension: gains required get_metric_impacts() -> list[MetricImpact]. Custom third-party implementations must add this method (returning [] is fine); without it, isinstance(source, SemanticSource) returns False. All built-in sources (YamlSource, DbtSource, CubeSource) implement it — no migration for users of the built-in sources.

Full Changelog: v0.9.2...v0.10.0