Skip to content

feat(artifacts): verify, preserve, and attribute evaluation artifacts - #288

Open
mchillakuru wants to merge 3 commits into
responsibleai:mainfrom
mchillakuru:feat/artifact-trust
Open

feat(artifacts): verify, preserve, and attribute evaluation artifacts#288
mchillakuru wants to merge 3 commits into
responsibleai:mainfrom
mchillakuru:feat/artifact-trust

Conversation

@mchillakuru

Copy link
Copy Markdown

Summary

Three commits.

Cache integrity. finalize_artifact_plan records a sha256 for every output, but the
cache activated a version on an existence check alone. An artifact altered after it was
written — by an interrupted run, a stray editor save, or anyone with write access to the
suite directory — was reused as though it were the computed result, and every score
derived from it looked legitimate. A mismatch is now a cache miss, so the stage
recomputes, which is what the operator wanted anyway.

Preservation. Resume and --force-stage called unlink() on inference_set.jsonl
and scores.jsonl. Those files are the evaluation evidence, and a config hash mismatch
is not always what the operator intended, so an accidental edit cost a full re-run with
no record that anything had been destroyed. They are now renamed aside with a timestamp.

Provenance. Stages hand work to each other through files, and no stage could tell
whether its input came from the previous stage, a cache hit, or someone editing the
file. A sidecar now records the producing stage, the assert-ai version, the run id, and
a digest of the artifact's bytes.

Closes: SEC-005 (cache reused without integrity verification) · SEC-021 (implicit inter-stage trust) · AP-010 (no schema version) · AP-013 (destructive resume)

Commits

  • feat(artifacts): record artifact provenance across stage boundaries
  • fix(artifacts): preserve stale artifacts on resume and stamp a schema version
  • fix(cache): verify artifact hashes before reusing a cached stage

Testing

pytest tests/ -q

Full suite green on this branch; 9 files changed, 612 insertions(+), 19 deletions(-). Every change has a regression test, and the
suite was re-run after each commit rather than only at the end.

Notes for reviewer

The schema stamp is a sidecar, not a header line, and that is deliberate. A version
header inside a JSONL file would be parsed as a data record by any reader that does not
know about it — including the previous version of ASSERT — so the stamp would corrupt the
first row on revert. A sidecar is ignored harmlessly instead.

Backups are never pruned automatically, because pruning evidence is the behaviour being
fixed. ASSERT_DISCARD_STALE_ARTIFACTS=1 restores the previous delete.

Artifacts with no stamp predate this and are still treated as compatible, so existing
runs keep working.

core.artifact_cache had its own file_sha256 that read whole files into memory; both
now share one chunked implementation so the cache digest and the provenance digest cannot
drift apart.

Risk and rollback

Each commit is a single concern and can be reverted independently. See the notes above
for anything that does not revert cleanly.

Murali Chillakuru added 3 commits July 30, 2026 09:54
finalize_artifact_plan records a sha256 for every output in file_hashes, but the cache activated a version on an existence check alone. An artifact altered after it was written - by an interrupted run, a stray editor save, or anyone with write access to the suite directory - was reused as though it were the computed result, and every score derived from it looked legitimate.

_metadata_outputs_exist becomes _metadata_outputs_valid and now compares each output against its recorded hash. A mismatch is a cache miss, not an error: the stage recomputes, which is what the operator wanted anyway, and the mismatch is logged with the file name and both hash prefixes.

Metadata without file_hashes predates hash recording and still passes on the existence check, so caches built by earlier versions are not discarded. ASSERT_SKIP_CACHE_VERIFY=1 skips hashing for very large artifacts, at the cost of an unverified cache.

_is_local_edit already hashed the suite-root working copies, but that protects hand-edits from being overwritten and never checked the cached artifact itself.
… version

Resume and --force-stage called unlink() on inference_set.jsonl and scores.jsonl. Those files are the evaluation evidence - every transcript, every verdict - and a config hash mismatch is not always what the operator intended, so an accidental edit cost a full re-run with no record that anything had been destroyed.

archive_artifact() renames the file to <name>.<UTC timestamp>.bak beside the original and logs the path. Backups are never pruned automatically, because pruning evidence is the behaviour being fixed; ASSERT_DISCARD_STALE_ARTIFACTS=1 restores the previous delete. A failed rename falls back to removal with a warning rather than failing the stage.

Only metrics.json carried a schema_version, so an artifact written by a newer ASSERT was consumed silently by an older viewer or analysis script. taxonomy.json and manifest.json now carry one inline, and the JSONL artifacts get a .schema.json sidecar.

The sidecar is deliberate: a version header inside the JSONL would be parsed as a data record by any reader that does not know about it, including the previous version of ASSERT, so the stamp would corrupt the first row on revert. A sidecar is ignored harmlessly instead. Artifacts with no stamp predate it and are treated as compatible.
Stages hand work to each other through files, and no stage could tell whether its input came from the previous stage, from a cache hit, or from someone editing the file. Only system_prompt_sha crossed a stage boundary; the taxonomy, test set, inference set and scores carried no provenance at all.

The schema sidecar added in the previous commit is extended rather than introducing a second mechanism: it now records the producing stage, the assert-ai version, the run id, the target model where relevant, and a sha256 of the artifact's bytes. verify_artifact_provenance() reports when a file no longer matches what its producer recorded, naming the stage.

The version is read from package metadata rather than hardcoded, so a provenance record cannot claim a version the running code is not; the CLI's separate hardcoded 0.1.0 is left alone as a follow-up.

core.artifact_cache had its own file_sha256 that read whole files into memory. Both now use one chunked implementation in core.io, so the cache digest and the provenance digest cannot drift apart.

Unstamped artifacts predate this and still verify, so existing runs keep working.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant