Releases: flyersworder/agentic-data-contracts
Release list
v0.49.0 — a shared driver's second edge
walk_metric_impacts reports a shared driver's second edge (#83)
The walk was a node-visited BFS: it marked a metric visited the first time any edge reached it and dropped every later edge onto it. A shared driver — one metric that is an operand of two different parents — was therefore reported on whichever branch happened to be walked first, and its other edge never arrived, taking with it the operator and convention that only that edge carried.
The loss was silent: no error, no warning, no note. The result read as a complete walk.
depth 1: paying_users -> revenue op=sum conv=None
depth 1: new_revenue -> revenue op=sum conv=None
depth 2: conv -> new_revenue op=product conv=fold_into
paying_users -> new_revenue is absent above — declared, product, convention: fold_into, and never delivered to the agent.
A shared driver is not an edge case in a metric tree. "The same factor moves both halves of the split" is precisely what a root-cause walk exists to surface, and it was the one thing this walk could not report.
The change
visited now gates expansion only, which is the sole thing it was ever needed for: each reachable metric is expanded at most once, while every declared edge between expanded metrics is reported.
Not a new principle — test_parallel_edges_to_one_neighbor_are_all_reported already said so in its own docstring: "visited tracking exists to stop cycles, so it must gate which nodes get expanded, not which edges get reported." It had been applied within a single node's adjacency, and now applies across the walk.
Cycle-closing edges are now reported. In a -> b -> c -> a walked downstream, c -> a appears. The edge is declared, and an agent tracing root cause should be told the graph closes; termination was never guaranteed by the reporting gate.
The parallel-edge rule from v0.46.0 is untouched: two edges equal in every field carry one fact and report once; two differing in any field are two declarations and both report.
The guarantee reads "between expanded metrics" deliberately: a node reached at exactly max_depth is never expanded, so its outgoing edges are not reported even when both endpoints were reached.
trace_metric_impacts caps at 200 edges
Reporting grew from O(V) to O(E), and that output lands in an agent's context. The walk stays complete and uncapped — a graph primitive that silently truncates is the same class of loss this release fixes — so the cap sits in the tool, where the max_depth clamp already lives.
Truncation keeps a BFS prefix: the nearest N edges, always connected to the queried metric. Within one node's adjacency, identity edges are indexed ahead of influence edges, so exact arithmetic outranks hypotheses. When truncation does drop identity edges, the note says so and names kinds="identity" as the remedy — a dropped identity edge must not be silent. The note advises only what a caller can act on: lowering max_depth only when that would change the result, and narrowing by kinds only when the walk holds both kinds.
The cap can truncate a response that was complete on v0.48.0 — a star graph of one metric driving 100 others already returned 100 edges. No shipped semantic layer comes close; they report at most 5.
Full changelog: https://github.com/flyersworder/agentic-data-contracts/blob/main/CHANGELOG.md
v0.48.0 — grading a declared breakdown
evaluate_conformance can grade a certified breakdown (#85)
Pass 3 scores two orthogonal axes. For a row certified with expected_rows, only the protocol axis was ever checked: whether the agent followed the governed path was graded in full, while whether it got the right numbers reported answer="skipped".
The failure that slipped through is a contract that drifts without breaking — a metric description trimmed so it no longer says which column identifies a region — leaving an agent that followed every rule to return a right-shaped, wrong-grouped answer against a green gate.
Attempt gains final_rows / final_columns, the breakdown counterpart to the existing final_answer. Both are accepted by Attempt.from_session.
attempt = Attempt.from_session(
example, session,
final_rows=[["Europe", 7200.00], ["North America", 2700.00]],
final_columns=["region", "revenue"],
)
# -> answer="match", row_differences=[], actual_row_count=2The host declares; the library does not infer. This is the same call final_answer already makes. _select_answer picks a scalar by clustering candidates and marks the guess when it is ambiguous, excluding it from ok. There is no equally honest inference for a breakdown: choosing whichever query happened to match would let a lucky drill-down pass — exactly the guess last_scalar exists to refuse.
Grading delegates to the same compare_rows that pass 2 uses, so a breakdown scores identically in both passes, with tolerance and naming which group differed. ConformanceResult gains row_differences and actual_row_count.
Declaring final_rows on a row certified with a scalar expected is ignored rather than honoured — a host may reasonably wire it uniformly, since an agent's result is a table for every question. A declared answer that reached no successful run_query reports protocol="contaminated", the verdict a declared scalar already earned: pass 3 asks whether the answer came through the governed path, and one that never queried did not.
Not breaking
A host that has not wired final_rows sees exactly the verdict it saw before: an undeclared breakdown still reports skipped and still passes, and report.ok semantics are untouched.
Scope
Automatic grading remains open — there is no inference of which recorded query held the breakdown, and no row retention on ToolCall. That design was considered and rejected as disproportionate to a half-gap on one axis with no consumer blocked on it.
Full changelog: https://github.com/flyersworder/agentic-data-contracts/blob/main/CHANGELOG.md
v0.47.0 — certified answers for breakdowns
expected_rows: a certified answer for a breakdown, not just a scalar (#82)
expected is a single float. A corpus row whose answer is a GROUP BY — revenue by region, a top-N list — had nowhere to put its answer, so check_example_answers skipped it silently: contract-compliant SQL, no assertion, nothing red. Nine of the fifteen rows in this repo's own corpus are that shape, including the first one.
expected_rows is expected's sibling for exactly that shape. A row declares one or the other, never both.
- id: revenue-by-region
question: "total revenue by region"
sql: SELECT region, SUM(amount) FROM orders GROUP BY region ORDER BY 2 DESC
expected_rows:
- [Europe, 7200.00]
- [North America, 2700.00]
- [Asia Pacific, 800.00]Row identity is inferred, not declared. A numeric cell is a measurement, and so is a null — a group whose measurement is absent is still a measurement, and a row cannot identify itself by a cell holding nothing. Everything else is a key. This is the opposite of the call made for convention, and deliberately so: which factor absorbs a cross term is not derivable from a schema at any level of model intelligence, whereas a row's key is derivable from what the query actually returned.
Unordered by default. A GROUP BY without an ORDER BY has no guaranteed row order, so a correct query never fails on the order it happens to come back in. ordered: true opts into positional pairing for when order is the answer.
Refuse vs. report. Three faults raise rather than being guessed at — a column-count mismatch, a duplicated group in the result, a non-numeric cell where the certified answer holds a measurement. This mirrors the scalar path, which already refuses a non-scalar result rather than calling it a wrong answer: the query did not answer the question that was asked. The batch guard turns each into status="error", distinct from mismatch.
Differences are named, not counted: missing group, unexpected group, and value mismatch (with its diff). Value mismatches are listed first, because a caller rendering only the first few still reports the group and row counts beside them — so a group-set difference is signalled even when unnamed, while a wrong number has no other signal.
Not in scope
evaluate_conformance is unchanged and still reports answer="skipped" for a breakdown row. Grading one there needs the recorder to carry more than the scalar / row-count / relative-time it holds today, and is deferred to #85 as its own design rather than as a side effect of closing this gap.
Full changelog: https://github.com/flyersworder/agentic-data-contracts/blob/main/CHANGELOG.md
v0.46.0 — trace_metric_impacts direction means drivers for both edge kinds
Fixed
-
trace_metric_impactssent a root-cause walk to the empty side of the graph (#81). Identity edges are canonicallyparent -> operand; influence edges aredriver -> affected. Those are opposite orientations for the same real relationship, so the one graph holding both could not answer "what drives this metric" by topology — and the tool's ownkindsdescription ("walk 'identity' first to localize the change") composed with itsupstreamdefault into a call that returned{"edges": []}. No error, no warning, and theconventionan attribution needs never arrived. Identity edges now enter the walk re-pointed operand -> parent via the newIdentityEdge.as_driver_edge(), sodirectionmeans the same thing for both kinds:upstreamis drivers,downstreamis what the metric feeds, and every returned edge points from a driver to what it affects. The canonical orientation is unchanged —lookup_metricandreconcile_decompositionread it as before.Breaking, for callers of this tool only. A walk that asked
direction="downstream", kinds="identity"for a metric's operands must now ask"upstream". An identity walk that comes back empty while edges exist on the other side now carries anotenaming that direction and how many metrics are there, so the migration reports itself instead of returning a bare[]. The note only suggests re-running the other way when the walk returned nothing at all — akinds="all"walk that found influence edges but no identity ones is told where the identity edges are, not to go and lose what it has. -
A pair declared twice reached the agent once.
walk_metric_impactsmarked a neighbour visited on the first edge that reached it, so with both kinds now pointing the same way, a metric declared as both an impact edge and a decomposition operand (asexamples/revenue_agent/declaresactive_customers) surfaced only whichever edge was indexed first — dropping theoperatorandconventionthat only the identity edge carries. Visited tracking now gates which nodes are expanded, not which edges are reported: each node is still walked once, cycles are still safe, and parallel edges — several found while expanding one node onto the same neighbour — are all returned. Edges equal in every field are the exception and report once, since identical declarations carry one fact.
Known limitation
- A shared driver across branches is still dropped (#83). If
adrives bothbandcandbis reached first, the edgea -> cis never reported, taking itsoperatorandconventionwith it — the same silence this release fixes, one hop deeper. The fix above covers parallel edges within a single node's adjacency, which is what the mixed influence + identity graph needed; the cross-branch case is the node-visited BFSwalk_metric_impactshas always been, and closing it changes a contract rather than fixing a bug (cycle-closing edges would start being reported, and the walk's "each reachable metric appears at most once" guarantee would go). Tracked in #83 rather than folded in here. The docstring anddocs/architecture.mdstate the limit rather than implying otherwise.
Internal
-
The
examplesCI job now diffs each example's whole stdout against a committed golden file (examples/*/expected_output.txt, regenerated byscripts/regen_examples.sh) instead of grepping for markers. The old gate asserted 5 markers across three examples that print 27 sections, and it stayed green while this release leftgrowth_agent's identity section printing{"edges": []}under a heading promising a decomposition. Per-section markers do not scale — one judgment call per section, one forgotten marker per section added — whereas a snapshot covers a new section the moment it is regenerated, catches the empty-payload case a heading grep sails past, and puts the change in front of a PR reviewer rather than only in a CI log. -
The three example demo queries gained an
ORDER BY. All of them relied onGROUP BYalone for row order, andrevenue_agent's rows genuinely reordered between runs — latent flakiness the marker greps never surfaced, and a blocker for diffing output. Fixed at the source: aGROUP BYwhose rows an agent reads should be ordered anyway.
v0.45.0 — Does the contract still teach?
Does the contract still teach?
A contract can decay in three ways, and until now this library could see two of them.
validate_examples asks whether the certified SQL is still allowed and plannable. check_example_answers asks whether it still returns the right number. Both check a query a human already got right, and neither involves an agent — so neither can see a contract that stays enforceable and accurate while quietly ceasing to be usable.
Rename a metric. Trim a domain description. Delete the sentence that said which order status counts as revenue. Enforcement is untouched, the certified SQL still returns 10700.00, both passes stay green — and an agent reading that contract can no longer find its way to the query. The contract stopped teaching, and nothing said so.
evaluate_conformance is the third pass over the same corpus: can an agent reproduce the certified answer from the contract alone, through the governed path? The progression is enforceable → accurate → teachable, and only the last of the three degrades silently.
The library never runs your agent
You wire the agent the way you already do, give it one ContractSession per question with a ToolRecorder attached, and hand back what the session recorded:
attempts = []
for example in corpus: # rows carrying a `question`
session = ContractSession(contract, recorder=ToolRecorder())
tools = {t.name: t.callable for t in create_tools(
contract, adapter=adapter, semantic_source=semantic,
session=session, caller_principal=example.principal)}
final_text = await your_agent_loop(example.question, tools)
attempts.append(Attempt.from_session(example, session, final_text=final_text))
report = evaluate_conformance(attempts) # pure: no network, no database, no model
print(report.summary()) # markdown, ready to post as a PR commentEverything expensive and nondeterministic happens in your loop, above the call. That is what makes the verdict logic testable without an API key and reproducible from a saved run.
The recorder rides on ContractSession, which all four framework entry points already accept. No public signature changed, and one implementation instruments every framework.
Nothing to judge is not couldn't judge
Two orthogonal axes, five states each. The answer axis scores the number: match, mismatch, unassertable, skipped, error. The protocol axis scores the path: followed, violated, contaminated, not_applicable, unchecked.
The distinction that makes the gate mean anything is between nothing to judge and couldn't judge. skipped and not_applicable pass — no assertion was made, no rule was activated, and there is nothing to hold against the contract. error and unchecked fail — something was supposed to be judged and the evaluation could not do it. Conflating those two is the classic evaluation bug: a suite reporting a serene green because every case was quietly skipped. For the same reason report.ok is False on an empty report.
A third field, answer_source (declared / sole_scalar / last_scalar / none), records how the answered number was picked and stays separate from the verdict. A row that matched on an ambiguously-selected last_scalar still reports answer="match" and is still excluded from ok — the verdict and the evidence for it are different fields, so nothing hides how it was derived.
The closed world is proved, not promised
evaluate_conformance judges whether an agent stayed inside the contract, which is only meaningful if it could have left. That requirement is enforced by derived evidence, never by assertion.
You are not asked to promise a closed world. An answer declared with zero successful run_query calls proves by construction that the number came from somewhere else, and the row is marked contaminated. If you do have full framework logs, Attempt.foreign_tool_calls accepts the names of non-contract tools that were available; it feeds nothing but that one verdict, keeping arbitrary trace formats out of the reasoning path.
The documented limit is stated rather than hidden: an agent that used run_query but drew its business context from a foreign retriever leaves no detectable trace at all.
A protocol failure requires a rule the row activated
expects_metrics is opt-in. A row that declares nothing lands on not_applicable, which passes.
- id: acme-completed-revenue
question: "total completed revenue for acme"
sql: SELECT SUM(amount) FROM analytics.orders WHERE tenant_id = 'acme' AND status = 'completed'
expected: 10700.00
expects_metrics: [total_revenue] # lookup_metric must precede the answering queryInferring whether a metric was "required" from the SQL was rejected: "how many rows in orders?" legitimately needs no metric, and any inference rule eventually calls that a violation. It matters because these findings are meant to drive contract-prose edits — a guessed violation becomes wrongly-rewritten documentation.
The same principle decides what happens when the harness cannot tell which query answered. With no scalar result and no declared answer, nothing in the record says which of several successful queries was the answer — so the guess is refused rather than taken silently, and the row reports unchecked. Refusing costs a false negative on a gate that already fails; taking the guess would let a lookup_metric landing after the real answering query, but before a trailing drill-down, read as compliant — a violation turning into a pass.
Also fixed
run_query no longer raises TypeError on a single non-numeric result. SELECT MAX(created_at) ... crashed the governed query path: the guard caught ValueError but not the TypeError from float(date).
Governance refusals are now distinguishable from failures. _error_response gained a kind axis (blocked vs error), classified semantically at all 21 call sites rather than by whether the message begins with BLOCKED — four denials lack that prefix and one non-denial has it. The key does not cross the MCP boundary.
Before you upgrade
Nothing here is breaking. The API is purely additive: recorder= is keyword-only and defaults to None, evaluate_conformance is opt-in, and there are no new dependencies. A codebase that ignores this release behaves exactly as it did on v0.44.1, with one exception — the run_query TypeError fix, which turns a crash into a result.
One known limitation, documented rather than silent. The budget pre-check in tools/sdk.py and tools/middleware.py returns before the inner tool closure, so an attempt stopped by an exhausted budget records no tool call — which the contamination rule would read as an answer produced outside the governed path. Pass error= to Attempt.from_session for such runs; details are in CHANGELOG.md.
expects_metrics becomes a recognised corpus key. from_dict preserves unrecognised keys under .metadata untouched, so a corpus already using that name for its own purpose changes behaviour rather than carrying through inertly. A corpus not using the name is unaffected.
A runnable end-to-end demo with a scripted stand-in agent — no API key, no network — is examples/revenue_agent/evaluate_conformance.py.
v0.44.1 — Across the mcp 2.0 boundary
A maintenance release with one load-bearing change
Both lockfiles and the pre-commit hooks move forward. anthropic goes 0.122.0 → 1.0.0, openai (in experiments/) 2.41.0 → 3.3.1, mcp 1.29.0 → 2.0.0, alongside pydantic-ai-slim 2.33.0, langchain-core 1.6.0, claude-agent-sdk 0.2.144 and websockets 16.1.1 — 24 updates in the root lock. Hooks: ruff v0.16.4, ty v0.0.73.
Only one of those needed code.
The ceiling moved upstream, as designed
The [agent-sdk] extra deliberately never duplicated claude-agent-sdk's mcp<2.0.0 ceiling — the Server.list_tools call that mcp 2.0 changed is the SDK's, not ours, and a copied ceiling goes stale. It since did: 0.2.144 widened to mcp<3.0.0,>=1.23.0, and this extra picked mcp 2.0 up with no edit.
mcp 2.0 moves mcp.types into a separate mcp-types distribution and renames the model attributes to snake_case — readOnlyHint → read_only_hint — while keeping the camelCase alias the protocol actually sends.
A kwarg that only worked by accident
_annotations_for built its read-only hint as ToolAnnotations(readOnlyHint=True). That still works on mcp 2.0 — but only by way of alias validation, so it names a field that no longer exists and reads as an argument silently discarded. ty v0.0.73's pydantic plugin reports it as exactly that.
It now builds from the wire representation instead:
ToolAnnotations.model_validate({"readOnlyHint": True})which resolves to the field name on mcp 1.x and to the validation alias on 2.0. The emitted annotation is byte-identical either way.
Both spellings have to keep working: the declared floor mcp>=1.23.0 is unbounded above, so test-lowest-floors resolves 1.23 while the lockfile carries 2.0. That is also why the annotation tests now read back through model_dump(by_alias=True) rather than attribute access — a test written the natural way passes on one side of the range and fails on the other, so no single environment can catch the mistake. docs/architecture.md records the rule, not just the example.
httpx2 is not httpx 2.x
Worth stating plainly, because the name invites the opposite conclusion. anthropic 1.0 and openai 3.0 both moved to httpx2, a separate distribution and top-level module. It installs alongside the httpx<1.0.0 that langchain-core still requires rather than competing with it, so the migration is additive at the resolver.
No impact on this library: src/ imports neither httpx nor any vendor client, and mcp.types is its only third-party runtime surface outside sqlglot, pydantic and pyyaml.
Upgrading
Nothing to do. No public API changed, no dependency floor moved, and the MCP annotation this release rewrites emits the same bytes it did before.
Installs of the [agent-sdk] extra will now resolve mcp 2.0 rather than 1.x. If you pin mcp yourself, anything from 1.23.0 up is supported and tested — CI exercises 1.23 and 2.0 on both Python 3.12 and 3.13.
mcp 2.0 requires pydantic>=2.12; this package's own pydantic>=2.11 floor is unchanged and still true, because the lowest-direct resolve takes mcp 1.23 with it.
Full detail in CHANGELOG.md.
v0.44.0 — Compliance is not correctness
Compliance is not correctness
validate_examples re-checks every corpus example against the same Validator that gates live agent queries — allowed tables, tenant filter, no SELECT *, plus a live EXPLAIN. It proves an example is allowed. It cannot prove the SQL is right.
A query with every table permitted, the tenant filter present and explicit columns can still sum the wrong rows, come back status: "valid", and be indistinguishable from a correct one. This release closes that gap.
VerifiedExample gains four optional fields — expected (the certified scalar answer), rel_tol, abs_tol, time_scoped — and a second pass, check_example_answers, executes the compliant asserted rows and compares:
report = validate_examples(examples, contract, explain_adapter=adapter)
answers = check_example_answers(report, adapter=adapter)
if not (report.ok and answers.ok):
sys.exit(1)Each asserted row lands in match, mismatch, unassertable, or error.
The checker consumes the report, not the examples
A row that violates the tenant-filter rule is precisely the query that must not be sent to a warehouse to see what it returns. Taking ExampleValidationReport as the input makes that ordering a property of the signature rather than a rule in a docstring — no ordinary call shape hands it unvalidated SQL. A row runs only when it is status == "valid" and declares an expected.
The adapter kinds differ for the same reason: validate_examples takes a plan-only ExplainAdapter, while the execute-capable DatabaseAdapter enters only at this second, already-filtered stage.
A relative time window is refused, not executed
An expected pinned against WHERE created_at >= CURRENT_DATE - 30 decays on its own — correct today, red in a month for a reason the corpus author never touched. Such a row is marked unassertable and never run. Set time_scoped: true when the window is pinned some other way.
Tolerance
Default rel_tol=1e-9, deliberately far tighter than reconcile_decomposition's 1e-4, and anchored on expected rather than on the larger magnitude the way math.isclose anchors. An assertion has a privileged side — the certified answer is fixed and the query result varies against it — unlike a decomposition identity, which compares two measurements with neither privileged. One consequence worth knowing: at expected == 0 the relative term vanishes, so a zero-valued assertion matches only exactly unless you also set an abs_tol.
Before you upgrade
Two changes are visible on upgrade. Both are detailed in CHANGELOG.md.
Four corpus keys stop being free-form. from_dict preserves unrecognised keys under .metadata untouched — a documented feature of the corpus format. expected, rel_tol, abs_tol and time_scoped are now recognised, so a corpus already using one of those names for its own purpose changes behaviour rather than carrying through inertly. A non-numeric value (expected: "one row per region" is plausible in a question-to-SQL corpus) now raises out of from_dict and aborts the load of the whole corpus; a numeric value that meant something else now reads as a certified answer, so that row's SQL gets executed and reported as a mismatch against a number that was never an answer. Neither is destructive — the second pass only reads — but rename the offending key before upgrading. A corpus using none of the four names is unaffected.
The sqlglot floor rises from >=28.0 to >=28.6. The relative-time scan names exp.Localtime, exp.Localtimestamp and exp.Systimestamp, which first exist in 28.6 — below it the package does not import at all. The tuple is deliberately not filtered with getattr: on older sqlglot a bare LOCALTIMESTAMP parses as a column, and the name arm matches only function calls, so a filtered tuple would silently execute a row whose certified answer is pinned to a decaying window instead of refusing it. A floor that fails loudly beats a scan that quietly checks less in one environment than another.
Docs
See Asserting the certified answer, not just compliance in the README, and examples/revenue_agent/verify_examples.py for a runnable DuckDB-backed demo covering all three outcomes.
v0.43.1 — Precondition parity and a guard that was not guarding
A follow-up to v0.43.0, closing the three issues its own review raised. No new features; no change to anything a correctly-formed contract does.
Fixed
attribute_change validates operator and arity, like the sibling it mirrors (#72)
It is public, accepts an arbitrary MetricDefinition, and its own comments state it cannot assume the object came through validate_decompositions at load — but it re-validated only the convention. reconcile_decomposition validates operator and arity up front for exactly that reason, before running a single query.
Malformed decompositions therefore surfaced as interpreter errors from inside the arithmetic rather than the ValueError the module commits to:
| Input | Before | After |
|---|---|---|
ratio, 1 operand |
IndexError: list index out of range |
ValueError: operator 'ratio' requires exactly 2 operands, got 1 |
ratio / difference, 3 operands |
too many values to unpack (expected 2, got 3) |
ValueError: ... requires exactly 2 operands, got 3 |
product, 0 operands |
ZeroDivisionError: division by zero |
ValueError: ... requires at least 2 operands, got 0 |
The IndexError came from the zero-denominator guard indexing operands[1], so the new block sits before it. Unreachable for any contract-loaded metric — the loader rejects all four shapes — but the asymmetry between two kernels with the same stated precondition posture is the kind that gets copied into a third.
Internal
The pinned round-trip digest now covers metric serialization (#73)
_PINNED_ROUNDTRIP_DIGEST is the repo's broadest guard against canonical-bytes drift — one changed byte anywhere fails it, with no test author needing to have anticipated the field. It was computed over a semantic source with no metrics: key at all, so _dump_metric was never called.
That is worse than a missing test, because the assertion passed either way. Every optional field added to _dump_metric since the pin was written could have been made unconditional — moving the digest of every real contract and invalidating every published ARD attestation — with the test still green. Verified by mutation: removing the if m.decompositions: guard now fails the pin, and before this change that mutation was unreachable.
Convention-default helpers moved to semantic/base.py (#74)
OssieSource was importing two private functions across from yaml_source.py, while every other shared semantic helper lives in base.py. Both operate purely on MetricDefinition / Decomposition, and both had to import VALID_CONVENTIONS and _CROSS_TERM_OPERATORS back out of base.py — constants that had no other consumer in yaml_source.py. After the move that file needs neither.
Upgrading
Nothing to do. No public API changed, and a contract that loads today produces the same canonical bytes and the same digest it did on 0.43.0.
v0.43.0 — Declared attribution convention
Closes #67.
Why
Asked "how much of the change came from each factor?", an agent produces contributions that always sum to the observed change but place the ΔC·ΔP cross term differently across runs. A 16-session pilot found three distinct placements on identical data, a 13.5% swing on the headline contribution, and the narrative conclusion flipping between them — with 3 of 16 runs disclosing no convention at all. Every placement is defensible; none is detectable from the output.
The pilot also disconfirmed the other hypothesis: 16/16 runs were arithmetically correct. The arithmetic was never the problem. The convention is.
Declare it
decomposition_convention:
convention: split_evenly # source-wide default
metrics:
- name: activations
decompositions:
- operator: product
operands: [volume, rate]
convention: fold_into # explicit | split_evenly | fold_into
convention_operand: rate| Convention | Cross term | Known as |
|---|---|---|
explicit |
reported on its own line, attributed to no factor | — |
split_evenly |
divided equally among the operands | Shapley, at two operands |
fold_into |
absorbed entirely by convention_operand |
Laspeyres / Paasche, at two operands |
Only product and ratio have a cross term. The source-level default is resolved onto each decomposition at load, so a frozen contract states its effective convention outright.
Delivery
Both tool channels an attribution question already touches: lookup_metric and, via IdentityEdge, trace_metric_impacts — the second is not optional, since that tool's own description tells the agent to "walk 'identity' first to localize the change" for root cause, which is the attribution workflow.
Kernel
attribute_change / check_attribution in agentic_data_contracts.validation — pure arithmetic, values in and contributions out, no adapter. Deliberately not wired into create_tools(): such a tool would fire at the last step to do arithmetic the pilot shows the agent performs correctly, and its only real content is the convention both channels already deliver.
This is grounding, not enforcement. An attribution report is prose and never passes a checker the way SQL does. check_attribution's intended caller is an eval harness measuring whether an agent follows the contract — not CI, not production.
Upgrade notes
SEMANTIC_KEYSgaineddecomposition_convention. It is interpreted vocabulary now. A contract listing it underexpected_extrasshould drop it — harmless either way, since a stale entry is a no-op rather than an error.- Digests are unmoved. Both new keys are omitted when unset at every serialization site, so a contract declaring no convention produces byte-identical
contract_canonical_bytes. - Decomposition operands must be unit- and precision-compatible (#68): a percentage-scaled rate makes a
productidentity false by ~100×, and an operand declared at limited precision needsrel_tolwidened to match.
v0.42.0 — Apache Ossie semantic source
Adds OssieSource, a fourth SemanticSource implementation reading Apache Ossie (incubating) models — the vendor-neutral spec, formerly Open Semantic Interchange, now under the ASF.
semantic:
source:
type: ossie
path: "./semantic/model.yml"Ossie standardises what a metric is; this library enforces what an agent may do with it. Nothing in the spec or its roadmap validates a generated query against a policy, so Ossie is a fourth input to the protocol rather than a competitor to the enforcement layer.
Four decisions define the adapter, each a place where the easy choice is a silent correctness bug:
- Table keys drop the database qualifier, because
Relationshipendpoints areschema.table.column. - Cardinality is derived from both sides. Ossie never writes the join type down, and
RelationshipChecker._check_fan_outfires only onone_to_many— so trusting the spec's "tois the one side" would silently disable the row-multiplication warning on an aggregate. - Composite joins are skipped with a warning, not split into independently-wrong column pairs.
- Dialect is an opaque string, never validated against the enum, since the accepted expression-language proposal adds
Ossie_SQL_2026and makes it the default.
Governance vocabulary round-trips through custom_extensions. Ownership, review dates, tiers, decompositions, drill_by, and the metric-impact graph have no home in the spec — every $def in osi-schema.json sets additionalProperties: false — so they ride in the spec's own escape hatch and are revalidated on load. Every ai_context and every foreign vendor block is carried through get_extras() without interpretation.
Also in this release: parse_review_date and jsonify_extras promoted to semantic/base.py now that two sources need them, and all locked dependencies upgraded (sqlglot 30.14→30.17, pydantic-ai-slim 2.22→2.31, xxhash 3.8→4.0). The pinned contract_digest guard still holds, so published contract bytes and existing ARD attestations are unaffected.
Full detail in CHANGELOG.md.