Skip to content

Validate appended experiment versions before they are stored - #16

Merged
VivienP merged 1 commit into
mainfrom
fix/experiment-assertion-aggregate-invariant
Aug 15, 2026
Merged

Validate appended experiment versions before they are stored#16
VivienP merged 1 commit into
mainfrom
fix/experiment-assertion-aggregate-invariant

Conversation

@VivienP

@VivienP VivienP commented Aug 14, 2026

Copy link
Copy Markdown
Owner

The defect

Reopening the demo fixture a second time against the same database returned HTTP 422 from POST /experiments, carrying a raw validator dump:

experiment_request_invalid: 1 validation error for Experiment
  Value error, experiment contains duplicate assertions [type=value_error,
  input_value={'version': 3, 'technique...}, input_type=dict]
  For further information visit https://errors.pydantic.dev/2.11/v/value_error

The experiment could not be recovered: every later read failed the same way.

Root cause

add_user_assertion built the next version with Experiment.model_copy(update=...). Pydantic does not run model validators on model_copy, so the aggregate rule that assertion identities are unique never ran on the append path.

An assertion identity is content-derived (content_id("assertion", body)), so declaring the same value with the same evidence note against the same supplemented assertion yields a second assertion with an identical identity. That version was written to experiment_versions, and _load_version then failed to validate it on every subsequent read — including the POST /experiments replay path, which is why creating an experiment reported a validation error about an experiment it had not been asked to create.

The change

  • The next version is constructed through Experiment(...) so the aggregate validator always runs. An invalid version can no longer reach the repository.
  • A repeat is refused first, with a stable message, so the response says what is wrong rather than dumping a validator error.

Changing a declared value still goes through the existing append-only path: supersede the active assertion with a corrected one.

Deliberately not changed

The refusal maps to HTTP 422 through the existing _error helper. 409 Conflict is arguably a better fit for "this already exists", but that would mean a new typed error class and a separate API-semantics decision, so it is left for review rather than folded into a root-cause fix.

Already-corrupted rows in an existing database stay unreadable; this prevents new ones. No migration or repair command is included.

Validation

Gate Result
pytest offline suite 726 passed, 2 skipped
pytest tests/integration 121 passed against real PostgreSQL and MinIO
mypy --strict 96 source files, no issues
ruff format --check, ruff check pass
Pre-commit gate documentation, document manifest, artifact validation (8 bundles) pass

Both new tests were confirmed to fail before the change:

  • tests/domain/test_experiment_passport.py::test_repeating_an_identical_declaration_is_refused_before_it_is_stored — did not raise.
  • tests/test_experiment_adapter_parity.py::test_repeated_declaration_reports_an_actionable_message_not_a_validator_dump — the corrupt aggregate escaped the domain and raised inside the response model, reproducing the production symptom.

A second domain test asserts that an appended version validates as an aggregate, so an append that cannot be read back fails at the layer that produces it.

Verified end to end against a running stack

Driving the documented demo flow twice against docker compose --profile demo:

Step Before After
Run 2 POST /experiments 422, validator dump 200, existing version 2 returned
Run 2 repeat declaration corrupt version persisted 422 the experiment already records this assertion
Run 2 GET /experiments/{id} 422, unreadable 200

Failure matrix

Added F-067: appending a user assertion that would produce a version violating an aggregate invariant is rejected before persistence, with all prior versions retained. No existing row covered this; F-056 covers stale expected versions and idempotency-key reuse. SHA256SUMS.txt regenerated for the documentation change.

Related

The interface changes in #15 report this response cleanly and attribute it to the stage that issued the request. That branch does not depend on this one.

…ored

Appending a user assertion built the next version with `model_copy`, which
does not run model validators. The aggregate rule that assertion identities
are unique therefore never ran on the append path.

An assertion identity covers its content, so declaring the same value with
the same evidence note against the same supplemented assertion produced a
second assertion with an identical identity. The resulting version was
persisted and could not be validated again, so every later read of that
experiment failed: reopening the demo fixture returned HTTP 422 from
POST /experiments carrying a raw validator dump, and the experiment could
not be recovered.

The next version is now constructed through validation, and a repeat is
refused with a stable message before anything is written. A correction still
supersedes the active assertion, which was already the append-only path for
changing a declared value.

Recorded as F-067 in the failure matrix; no existing row covered an append
that would violate an aggregate invariant.
@VivienP
VivienP merged commit 61014c5 into main Aug 15, 2026
3 checks passed
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