Releases: flyersworder/agentic-data-contracts
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.
v0.11.0 — Tool surface consolidation (13 → 9)
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_schemasremoved: The allowed-schemas set is implicit in the allowed-tables list that the prompt renderer already injects.list_tablesremoved: The prompt renderer already injects the full allowed-tables list. Per-table column details remain available viadescribe_table.get_contract_inforemoved: 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 everyrun_queryresponse undersession.remaining.validate_query+query_cost_estimatemerged intoinspect_query: Both tools wrapped the same underlyingValidator.validate()call (which internally runs Layer 1 + EXPLAIN). The merge removes a "which tool do I call?" decision. Response is structured JSON withvalid,violations,warnings,log_messages,schema_valid,explain_errors,pending_result_checks, and — when an adapter is configured —estimated_cost_usdandestimated_rows.
Changed
run_queryresponse: Success responses now include asession.remainingblock mirroringContractSession.remaining()(elapsed seconds, retries remaining, token budget remaining, cost remaining). Blocked responses append a one-lineRemaining: {...}suffix with the same data.ValidationResultdataclass: Gains three additive fields —estimated_rows: int | None,schema_valid: bool = True, andexplain_errors: list[str] = []. Populated inValidator.validate()when anExplainAdapteris configured. Defaults are safe for existing callers.
Migration
- Replace
validate_query(sql)calls withinspect_query(sql). The response is JSON rather than a status string; parsevalid,violations, andwarnings. Cost and row estimates live under the same response. - Replace
query_cost_estimate(sql)calls withinspect_query(sql). Cost and row fields are now nested alongside validation fields. - If an agent previously called
get_contract_info, read remaining budget fromrun_queryresponses (data["session"]["remaining"]) instead. Static contract metadata is already in the system prompt. list_schemasandlist_tableshave no replacements — the prompt already contains this information.
v0.10.0 — Metric Taxonomy and Impact Graph
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: optionaldomains/tier(north_star/department_kpi/team_kpi) /indicator_kind(leading/lagging). All defaulted — existing contracts parse unchanged. MetricImpactdataclass — directed, annotated edges withdirection,confidence(verified/correlated/hypothesized), free-textevidencethe agent can cite verbatim. Declared via a top-levelmetric_impacts:block in the semantic YAML.trace_metric_impactstool (13th tool) — BFS through the impact graph.direction="upstream"returns drivers (root-cause analysis);direction="downstream"returns affected metrics (action-impact reasoning).max_depthclamped to[1, 10].build_metric_impact_index()andwalk_metric_impacts()helpers insemantic.base, mirroring the existingbuild_relationship_index/find_join_pathpattern. Dual-keyed index, cycle-safe BFS.get_metric_impacts()onSemanticSource—YamlSourceparses from YAML;DbtSource/CubeSourcereturn[](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.domainsread from each metric'smetadict inDbtSourceandCubeSource. String→list coercion fortier/domainsis 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,SemanticSourcenow re-exported from the top-levelagentic_data_contractspackage.
Changed
- Tool count: 13 tools (was 12).
lookup_metricresponse enriched withdomains,tier,indicator_kind, and citation-readyimpacts/impacted_byedges (e.g."positive impact on total_revenue (verified): A/B test exp-042, +3.2% lift"). Optional fields omitted when empty.list_metricsfilters: optionaltierandindicator_kindalongside the existingdomainfilter. Entries includetierandindicator_kindwhen set.list_metricsdomain semantics: filtering now unions contract-sideDomain.metricswith self-declaredmetric.domains. Metrics that self-declare a domain are discoverable via the filter even if the contract'sDomain.metricslist doesn't include them.- Example (
examples/revenue_agent/) demonstrates the new fields, an impact edge, and atrace_metric_impactsstep in the demo.
Breaking
SemanticSourceprotocol extension: gains requiredget_metric_impacts() -> list[MetricImpact]. Custom third-party implementations must add this method (returning[]is fine); without it,isinstance(source, SemanticSource)returnsFalse. 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