A consumer reports that their model’s behaviour changed. They are using “the latest” synthetic release, they did not change anything on their side, and the release passed every gate on the way out.
Part of Artifact Versioning & Lineage: this is the investigation that registry exists to make possible, and the procedure for running it in the order that eliminates the most per step.
Before touching the generator, establish which of three things actually moved. In practice the distribution is roughly even, and skipping the first two costs days.
The consumer’s pipeline changed. A library upgrade, a config edit, a different join. This is settled in one step by asking them to re-run their current pipeline against the previous release, which the registry can hand them by hash. If the behaviour reverts, the release is exonerated and the investigation moves to their side.
The release changed for a legitimate reason. A new region was added, a fallback rule fired more often, an input was updated. The output genuinely differs and no defect exists — but the consumer was not told, and “no defect” is not the same as “nothing to do”.
The generator changed. Something in the pipeline produces different output from the same inputs, and that is a defect.
Across simulated investigations: the first two branches account for the majority, and both are settled by a single query the registry can answer.
defresolve_consumer_release(registry, consumer:str)->dict:"""The consumption log answers this in one query — and nothing else does."""
events = registry.consumption(consumer=consumer, limit=20)ifnot events:raise SystemExit(f"{consumer} has no recorded consumption — they are bypassing resolve(). "f"Find out how before continuing; the rest of this procedure assumes the record.")return{"current": events[0]["artifact_sha"],"previous":next((e["artifact_sha"]for e in events
if e["artifact_sha"]!= events[0]["artifact_sha"]),None),}
If this raises, stop and fix that first. Every step below reads from the record, and an investigation that starts by guessing which release a consumer has is an investigation that will end by disagreeing about it.
defconfirm_release_implicated(consumer_harness, shas:dict)->bool:"""Re-run the consumer's current code against the previous release."""
now = consumer_harness.run(shas["current"])
before = consumer_harness.run(shas["previous"])return now.metric != before.metric
One run, and it splits the space in half. If the metric is the same on both, the consumer’s code changed and the release is not involved.
If several releases sit between the last known-good and the first known-bad, bisect rather than diffing the endpoints. The registry’s supersession chain gives the ordering, and each step is one consumer run:
python
defbisect_releases(registry, name:str, good:str, bad:str, harness)->str:
chain = registry.chain(name, from_sha=good, to_sha=bad)# ordered oldest → newest
lo, hi =0,len(chain)-1while hi - lo >1:
mid =(lo + hi)//2if harness.run(chain[mid]["artifact_sha"]).metric == harness.run(good).metric:
lo = mid
else:
hi = mid
return chain[hi]["artifact_sha"]# the first release exhibiting the change
defdiff_lineage(registry, a_sha:str, b_sha:str)->dict:
a, b = registry.get(a_sha), registry.get(b_sha)
inputs_a, inputs_b = a["input_shas"], b["input_shas"]return{"generator":(a["generator_version"], b["generator_version"]),"inputs_changed":sorted(k for k inset(inputs_a)|set(inputs_b)if inputs_a.get(k)!= inputs_b.get(k)),"contract":(a["contract_sha"], b["contract_sha"]),"privacy":(a["privacy_ledger_row"], b["privacy_ledger_row"]),}
Three of those four fields are usually identical, and whichever one is not is the cause. That is the entire payoff of recording lineage at promotion time: the question “what changed” becomes a dictionary comparison rather than an archaeology exercise.
The lineage diff between two adjacent releases: three fields match, one does not, and the one that does not is the cause.
defisolate(registry, bad_sha:str, changed_input:str)->str:"""Re-run the bad release's manifest with the previous version of one input."""
manifest = registry.manifest(bad_sha)
good_input = registry.get(registry.get(bad_sha)["supersedes"])["input_shas"][changed_input]
manifest["inputs"][changed_input]= good_input
return generate(manifest, out=Path("audit/isolated"))
If swapping that one input back reproduces the previous behaviour, the finding is complete and it is not a generator defect — an input changed and the release faithfully reflected it. If it does not, the generator version is the remaining candidate, and the diff between the two generator commits is now a small, bounded thing to read.
deftest_regression_fixture(fixture_dir):"""Whatever the cause, lock the behaviour that changed."""
manifest = json.loads((fixture_dir /"manifest.json").read_text())
out = generate(manifest, out=fixture_dir /"actual")
expected = json.loads((fixture_dir /"expected-metrics.json").read_text())
got = consumer_metric(out)assertabs(got - expected["metric"])< expected["tolerance"],(got, expected)
The fixture is the deliverable of the investigation, and it is worth more than the fix. It carries a manifest small enough to run in CI, the metric the consumer actually watches, and the tolerance that was agreed — so the next change that moves it is caught by the producer rather than reported by the consumer.
Two properties make a fixture useful rather than decorative. It must be small: a fixture that takes twenty minutes will be skipped, so cut the extent down until it runs in seconds while still exhibiting the behaviour. And it must assert the consumer’s metric, not an internal one — the whole point is that the internal gates passed.
The consumer cannot re-run against an old release. Their harness may not be parameterised by version. Getting them to that point is itself worth doing, because without it every future report is unattributable, and it is usually a small change to a config path.
The fixture outlives the investigation, which is why its properties are worth getting right.
The bisection is not monotone. Two independent changes in the range, one of which was later reverted. The bisection converges on the wrong release and the diff looks innocuous. The tell is that the metric moves back and forth along the chain rather than once, which is visible if the harness records every run rather than only the branch it takes.
The changed input is upstream of another artifact. The lineage walk recurses, and the changed thing may be several levels up — a boundary file feeding a tessellation feeding the release. That is the case the transitive closure exists for, and it is why a lineage graph beats a lineage string.
Nothing changed and the behaviour still differs. Then the release is not reproducible, and the investigation moves to auditing a run that cannot be reproduced. This is a different and worse finding than any of the above, because it means two releases with identical recorded inputs are not identical.
An investigation has an audience, and the reporting is part of the work rather than an
afterthought.
At the start, confirm what they are actually using and say so. “You are on 2026.4.0, promoted
on the fourteenth, superseding 2026.3.1” settles more than it looks: it establishes that the
producer can answer the question at all, and it frequently prompts the consumer to notice that
they upgraded at the same moment their behaviour changed.
When the release is exonerated, say that plainly and give them the evidence — their code
against the previous release produced the same result. This is not a deflection if it comes with
the hash and the harness output, and it usually shortens their own investigation considerably.
When the release legitimately changed, name the change and its cause: an input was updated, a
region was added, a fallback started firing. This is the case where “no defect” is genuinely the
answer and it is also the case where a release note should have existed and did not. Adding the
change to the release record afterwards is worth doing even though it is late.
When it is a generator defect, report the finding with the fixture rather than the fix. A
consumer who has been told “we fixed it” has no way to know whether it will recur; one who has
been told “here is a test that now runs on every release, asserting your metric within your
tolerance” has.
When nothing is found, say that too. An investigation that ends without a cause is a real
outcome and it happens: an intermittent difference in the consumer’s environment, a metric with
more run-to-run variance than anybody realised, a change neither side recorded. Closing it
explicitly, with what was eliminated, is far better than letting it fade — because the next
report of the same symptom then starts from the elimination list rather than from scratch.