Skip to content

feat: a digest of a spec's interface, and a check that a changed interface gets a new version - #193

Merged
SimonHeybrock merged 2 commits into
mainfrom
184-spec-digest
Nov 13, 2026
Merged

SimonHeybrock merged 2 commits into
mainfrom
184-spec-digest

Conversation

@SimonHeybrock

Copy link
Copy Markdown
Member

Stacked on #192 (base branch 184-adr-what-is-durable). Merge #192 first, or this PR lands in that branch, not in main.

First part of ADR 0009: a digest of a spec's interface in ess.spec, and a check for technique packages. Nothing in ess.apps changes yet; the next PR stores the digest in a workspace's log and reads a record of another spec as plain data.

The digest. WorkflowSpec.digest is a short hash of the name, type, and requiredness of every field of the params and outputs models, including the fields of groups nested in them. Two specs have the same digest when a request and an output stored under one still fit the models of the other.

class Centre(BaseModel):          # digest A
    x: float
    y: float

class Centre(BaseModel):          # digest B: a stored x no longer fits
    x_mm: float = 0.0
    y: float

What counts and what does not:

Counts Does not count
a field's name, type, and whether it is required titles, descriptions, defaults, constraints, validators
the fields of a group, at any depth a group's class name or module, and the order of fields
the values of a Literal or an enum the names of enum members
the metadata of a file field the spec's own name and version
the root type of a RootModel; module and name of any other class

The check. A technique package keeps its specs' digests in a file and compares them in its tests, so a changed model without a new version fails in the author's CI:

DIGESTS = Path(__file__).with_name('spec_digests.json')
check_digests(specs, DIGESTS)   # AssertionError: the interface of loki-iofq/v1 changed: give the spec a new version, or ...
write_digests(specs, DIGESTS)

The file's entries must match the given specs exactly. LoKI has the file and the test.

Choices to be aware of.

  • Requiredness of a param is in the digest although a stored request holds its defaults filled in. A param that becomes required breaks callers, which the check should report.
  • A reference Referenced[T] counts by T's fields. Replacing T by another class with the same fields keeps the digest; that case is caught when an old reference is checked, since a reference fits by class.
  • Changing the metadata of a file field, including the role of a raw file, changes the digest.
  • Specs now refuse serializers attached with Annotated, as they already refuse serializers written as decorators: they change the stored form unseen. A type that is not resolved raises an error that names the field.

Review. A fresh reviewer probed for specs with equal digests whose stored forms differ, and for digests that change without reason. Fixed from it: a class that is not a group was described by its bare name, so two RootModel classes of one name with different content had the same digest; unresolved forward references were hashed by their text; the check passed when given no specs. The digest does not depend on the hash seed or on how a union or a list is spelled (Optional[X] and X | None agree).

Not done here. The serialized form of a spec does not carry the digest; that is settled where the digest is first compared, in the next PR.

🤖 Generated with Claude Code

SimonHeybrock and others added 2 commits November 13, 2026 07:18
…rface gets a new version

An outputs or params model can change under the same spec name and version, and
a record made with the old models is then read into the new classes without an
error (ADR 0009). WorkflowSpec.digest is a short hash of the name, type, and
requiredness of every field of both models, at any depth, so that such a change
can be detected. Titles, descriptions, defaults, constraints, validators, field
order, and class names and modules do not change it.

ess.spec.testing.check_digests compares each spec's digest with a JSON file kept
in the package, so that a model changed without a new version fails in the
author's tests; write_digests writes the file. LoKI keeps its digests next to
its spec tests.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…vers every entry

A RootModel is described by its root type, and a class that is not a group by its
module and qualified name, so two classes of one name no longer share a digest. A
Literal and an enum of the same values have one description. A group's extra
setting counts. An unresolved type raises, naming the field, instead of hashing
its repr. A params type with a serializer written with Annotated is refused, as a
decorator serializer is.

check_digests fails for a file entry of no given spec, and both functions refuse
two specs of one name and version. LoKI's iofq digest changes with the Literal form.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

@SimonHeybrock SimonHeybrock left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@SimonHeybrock
SimonHeybrock merged commit bee8799 into main Nov 13, 2026
2 checks passed
@SimonHeybrock
SimonHeybrock deleted the 184-spec-digest branch November 13, 2026 09:00
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