This guide is for anyone who changes a payload type. That is the input, output or error type of a #[workflow], the input or output type of an #[activity], or the value type of a ctx.side_effect. It explains what docs/workflow-schema-contract.json is, how harvest schema check classifies a change, and how to acknowledge a deliberate migration.

Tracked by issue #794; builds on the published-schema surface from issue #373.


The problem it solves

A contributor renames a field:

Diff
 #[derive(Serialize, Deserialize, JsonSchema)]
 pub struct OnboardInput {
     pub user_id: i64,
-    pub email: String,
+    pub email_address: String,
 }

cargo build is green. Tests are green. The PR merges. On deploy, every in-flight onboarding execution fails: its recorded WorkflowStarted event carries {"email": …}, which no longer deserializes into OnboardInput, so the run wedges and eventually DLQs.

Nothing in the toolchain catches this, because the payload types compile fine — the incompatibility is with JSON already written to harvest_events, not with any other code.

harvest schema check is that missing gate. It diffs the schemas your app publishes right now against a checked-in baseline and fails the build on any change that would break replay.


The perspective: replay-read

Every verdict answers exactly one question:

Does JSON already recorded in harvest_events still deserialize into the new type, and still mean the same thing?

This is deliberately not general JSON Schema subtyping. It is asymmetric — the schema is a read contract for historical data — which is why, for example, adding an enum variant is safe (nothing recorded matches it) while removing one is not (recorded data does).


Quick start

Console
# 1. Generate what your app publishes right now (see "Generating --current").
$ cargo run --bin dump-schema-contract > /tmp/current.json

# 2. Gate it.
$ harvest schema check --current /tmp/current.json
schema check: no breaking changes (0 compatible delta(s))

A breaking change:

Console
$ harvest schema check --current /tmp/current.json
onboarding.input: /email — breaking — property `email` removed; the value recorded for it is
  silently dropped on replay, so the workflow no longer observes it. If this is a rename, add
  `#[serde(alias = "email")]` to the new field.
onboarding.input: /email_address — breaking — required property `email_address` added; JSON
  recorded before this change has no such key and fails to deserialize (serde: `missing field
  \`email_address\``). Make it `Option<T>` or add `#[serde(default)]` to keep in-flight
  executions replayable
schema check: 2 breaking change(s) of 2 delta(s)
$ echo $?
1

Generating --current

Harvest is a library: the workflow registry lives in your process, so only your crate can enumerate it. Add a short binary:

Rust
// src/bin/dump-schema-contract.rs
fn main() {
    let contract = autumn_harvest::WorkflowSchemaContract::from_infos(
        env!("CARGO_PKG_VERSION"),
        &registered_workflows(), // the same Vec<WorkflowInfo> you pass to .workflows(...)
    )
    .with_activities(&registered_activities()) // the Vec<ActivityInfo> you register
    .with_side_effects(&declared_side_effects()); // see "Activities and side effects"
    print!("{}", contract.to_json_pretty().unwrap());
}

Point it at the same Vec<WorkflowInfo> your HarvestPlugin/HarvestBuilder registration uses. The gate can only ever be as accurate as the registry it is handed, so sharing one function is the difference between a real gate and a decorative one.

This runs offline: no database, no server, no db feature.

autumn-harvest/examples/schema_workflow.rs is a working reference — run it with --emit-contract to see the generator in action.

Activities and side effects

An activity result and a side-effect value are read back on replay too (issue #1994). A workflow decodes a recorded ActivityCompleted result into its own type. It decodes a recorded side-effect value the same way. A retried activity decodes its stored input. A change to one of these types can break an in-flight run.

Publish their schemas the same way a workflow does:

Rust
// The `schema` feature derives each schema from the type.
let activities = vec![charge_info().with_schemas::<ChargeInput, Receipt>()];

// A side effect is keyed by its workflow and its id.
let side_effects = vec![checkout_info().side_effect("pick").with_schemas::<Variant>()];

let contract = WorkflowSchemaContract::from_infos(env!("CARGO_PKG_VERSION"), &workflows)
    .with_activities(&activities)
    .with_side_effects(&side_effects);

Without the schema feature, use with_input_schema_fn, with_output_schema_fn or with_value_schema_fn with a hand-written schema.

For an activity with more than one input parameter, the input is the tuple of the parameter types, for example with_schemas::<(String, u32), Receipt>(). A tuple schema fixes its length, so a new trailing parameter reads as a breaking arity change. At run time, a missing trailing element decodes as null. Acknowledge the delta if the new parameter is an Option.

Pass the same ActivityInfo values that you register with the runtime. An activity with no schema is still listed, so coverage shows it.

A side-effect declaration is not registered with the runtime. Only the contract reads it. Nothing checks that its id matches a ctx.side_effect call, so copy the id from the call site. A side effect with an id built at run time cannot publish a schema.

The full ruleset applies to these payloads. Four rules are new:

ChangeVerdictWhy
Add an activity or a side effectCompatibleNo history exists for it
Remove an activityBreakingThe contract list is not tied to the runtime registry. In-flight runs still decode its recorded results. Acknowledge it after those runs end and its queued tasks drain
Remove a side-effect declarationBreakingThe call site can stay after the declaration goes. A removal must not stop the check in silence. Acknowledge it when the call site is gone too
Remove a side effect together with its workflowCompatibleThe workflow removal is compatible, and issue #520 gates it

The contract gets two sections, activities and side_effects. The file omits each one when it is empty. The artifact format is contract_version 2. This build still reads a version 1 baseline, and refuses a version 1 label on a file with the new sections. A version 1 binary refuses a version 2 file, so it cannot skip the new sections in silence.

A bare GET /workflows/registered body has no activity or side-effect section. schema check and schema update refuse such a --current when the baseline has either section.

Limits. The gate checks the type that the activity or the side effect declares. A workflow can decode the recorded value into another type at its call site, and the gate does not see that type. The gate does not check an activity error payload.

Alternative: a running server

--current also accepts a raw GET /workflows/registered response body, so a staging deployment works with no post-processing. That body has no activity or side-effect section, so the CLI refuses it when the baseline has either:

Console
$ curl -s "$HARVEST_URL/workflows/registered" > /tmp/current.json
$ harvest schema check --current /tmp/current.json

(HARVEST_URL already includes the mount prefix — it defaults to http://localhost:3000/api/harvest — so the path above is not prefixed again.)

The generator is preferred for CI: it needs no live service, and it reflects the code in the PR rather than whatever is currently deployed.


The ruleset

Verdicts are computed from the replay-read perspective above. The full ruleset is also embedded in the artifact itself (.compatibility), so it travels with the file.

Breaking

ChangeWhy it breaks replay
Adding a required propertyRecorded JSON has no such key → serde missing field
Removing a propertyThe recorded value is silently dropped; a hard failure under #[serde(deny_unknown_fields)]
Renaming a propertySurfaces as removed + added-required — both halves reported
Making an optional property requiredRecorded JSON that omitted it now fails
Narrowing a type (string → integer; dropping null)Recorded values of the dropped type fail
Narrowing a numeric format (int64 → int32, i64 → u64)Recorded values outside the new range fail
Removing an enum value or a oneOf/anyOf variantRecorded data matching it fails
Restricting additionalProperties (absent → schema → false)Recorded extra keys are now rejected
Introducing or tightening a bound (minimum, maxLength, …)Recorded values outside the new bound fail
Changing tuple arityRecorded arrays of the old length fail
Introducing or changing a semantic format (String → Uuid, uuid → date-time)A non-numeric format is not decoration: schemars emits it because the field's Rust type parses the string, and that type's Deserialize rejects one that does not parse. A recorded arbitrary string stops deserializing the moment the field becomes a Uuid. (Numeric formats — int32, double, … — are orderable and compared on the width lattice instead.) The differ sees only the schema, so a purely descriptive #[schemars(format = "email")] on a plain String is flagged too — a one-time acknowledgeable cost, taken because the mirror mistake lets a real String → Uuid break merge silently
A cardinality bound (minLength, maxLength, minItems, maxItems) that is present but is not a non-negative integerThese count things, and the engine's validator reads them with as_u64, ignoring anything else. So "minLength": 1.5 is not a smaller bound than 1 — it is an absent one, and correcting it to 1 newly rejects every recorded empty string. Reading both through as_f64 would score that a relaxation
Withdrawing a published schemaCoverage regression: the type is no longer gated at all
Adding a oneOf/anyOf variant that is not disjoint from an existing oneRecorded data that matched exactly one branch may now match two — oneOf requires exactly one
Adding or removing an allOf conjunctallOf is an AND: adding narrows. Removing is also breaking — draft-07 additionalProperties does not see into subschemas, so dropping a member that declared properties turns those keys into "additional" and tightens the schema
Two indistinguishable oneOf/anyOf branches, and the branch count changedBranches are matched across revisions by identity; ambiguous keying means the differ cannot say which branch was added or removed, so it fails closed. (When the count is unchanged the branches are compared pairwise by position instead — see below.)
Inserting an anyOf variant ahead of a pre-existing branchanyOf is #[serde(untagged)] and serde binds the first matching variant. Prepending Float(f64) before Int(i64) captures every recorded integer — it still deserializes, but into a different variant. Appending after every existing branch is compatible (that is the T → Option<T> shape, which appends a null branch)
Adding a oneOf branch when any pair of branches is not provably disjointA tag on the added branch says it is narrow, not that its new neighbours are. Adding {"enum":["x"]} beside a broad {"type":"string"} makes the recorded value "x" match two branches, and oneOf requires exactly one. Carrying a discriminator each is not enough either: an internal tag on tag beside one on kind are both real tags, yet {"tag":"A","kind":"B"} satisfies both. A tag proves disjointness only when both branches pin the same key to different values, and only when that key is required — an optional tag: "A" still accepts {}. serde's externally-tagged shape proves it through additionalProperties: false, which is what makes each branch reject its siblings' key; two open single-required-field objects both accept {"A":…,"B":…} and are not disjoint at all. All of these hold for what schemars emits
Duplicating a oneOf branch pinned to a single valueoneOf requires exactly one match, so a value matching two identical branches is rejected. (Unit-only enum branches normally collapse into one flat enum array so annotation churn produces no delta; that collapse is skipped when it would dedupe a genuine duplicate away, and also when the branches do not all agree on type — including all agreeing on having none. Disagreeing types have nothing to lift, and collapsing anyway would drop them, making a set and the same set with its types swapped — which accepts nothing — canonicalize alike. anyOf is "at least one", where duplicates are harmless, so it still collapses)
Changing a non-final anyOf branch that is not provably disjoint from a later oneanyOf is #[serde(untagged)] and serde binds the first matching variant. Making branch 0's a optional, while branch 1 requires b, lets a recorded {"b": …} that bound to variant B match branch 0 and bind to variant A instead — it still deserializes, but it means something else. Exempt when the branch is provably disjoint from every branch after it (disjoint type sets, or two differently-tagged variants), which is what keeps Option<T> — anyOf: [{$ref: T}, {"type":"null"}] — compatible. Adding an optional property is also exempt, but only on a branch that tolerates unknown keys: it already accepted that payload with the key undeclared, so declaring it changes nothing. Under additionalProperties: false (serde's deny_unknown_fields) the same edit genuinely widens — the branch rejected {a,b} before and accepts it now — so the exemption is withdrawn
Changing the branch container anyOf → oneOfanyOf accepts a value matching two or more branches; oneOf requires exactly one. With integer and number branches a recorded integer matches both — accepted before, rejected now — even though no branch changed
Reordering anyOf branchesanyOf is #[serde(untagged)], and serde binds the first matching variant in declaration order. Swapping Int(i64) and Float(f64) rebinds a recorded integer to the float variant: it still deserializes, but it no longer means the same thing. (oneOf requires exactly one match, so order cannot affect binding — reordering it is not a delta.) Reported without proving the branches overlap; acknowledge it if they are disjoint
A node carrying both oneOf and anyOfOnly one is analysed as the branch container, so a change to the other cannot be classified
Changing an unresolvable $ref (external, dangling, cyclic)The target cannot be read, so the change cannot be classified
A bound that is present but not a JSON numberIt cannot be placed on the tighten/relax lattice; treating it as absent would report a malformed value as "removed" → compatible
An enum present in both revisions where either side is not a JSON arrayA validator ignores the unreadable side and enforces the readable one, so "enum": "x" → "enum": ["x"] newly rejects every recorded value outside the set. enum is nominally analysed, so it never reaches the fail-closed sweep — it fails closed here instead. Two identical malformed values are not an edit and emit nothing
Widening an existing oneOf branch until it overlaps a siblingoneOf requires exactly one match. Baseline branches {"maximum":0} and {"minimum":1} accept a recorded 1 exactly once; relaxing the first to {"maximum":2} reads as a compatible bound relaxation in isolation, but 1 now satisfies both branches and is rejected. Checked against every other branch, in both directions — unlike anyOf, where first-match binding means only the branches declared after this one can lose data, so widening the last oneOf branch is just as fatal as widening the first. Narrowing needs no such check — it cannot create an overlap, and the payloads it drops are already reported by the narrowing itself
Changing the default of a property that is optional on both sidesdefault is a pure annotation to a validator, but not to serde: #[serde(default = "retries")] records the computed fallback there. Change it from 3 to 5 and every recorded payload that omitted the key deserializes to a different value — the same JSON, a different meaning. Only compared where serde would actually substitute it; one beside a required key, or on a root or array-item schema, is never substituted and emits nothing
Declaring a property that does not accept every value the baseline allowed as an extraSerde ignores an undeclared key but parses a declared one. If the baseline said "additionalProperties": {"type":"integer"}, a recorded b: 1 was carried along as an extra; declaring b: Option<String> now fails to parse it. The test is containment, not overlap: extras typed ["string","integer"] and a new string property share string, yet a recorded 1 was accepted before and is rejected now. Containment is over the whole schema, not just type — every other constraint narrows further, so {"type":"string","enum":["x"]} contains the extras' string and still rejects a recorded "y". Full schema containment is not implemented, so any constraint the extras schema does not already demand identically fails closed; the one-directional cost is that a looser declared constraint (minLength 5 → 3) asks for an acknowledgement it does not strictly need. Only reported when provable — on a fully open object nothing is knowable in either direction, so it stays compatible (see the limits below)
additionalProperties present but neither a boolean nor a schemaA validator ignores a non-schema value, so it constrains nothing and ranks with true. Correcting "additionalProperties": "oops" to {"type":"string"} therefore restricts, and newly rejects every recorded non-string extra. Two different malformed values are both ignored at runtime and emit nothing
A checked-in acknowledgement whose reason is blankA rubber stamp is not an acknowledgement; the artifact is rejected
Any change to a constraint keyword outside the analysed setFail-closed — see below

Compatible

ChangeWhy it is safe
Adding an optional property (Option<T> or #[serde(default)])Recorded JSON that omits it still deserializes
Removing a required markerStrictly fewer keys are demanded
Widening a type (integer → number, T → Option<T>)Every recorded value is still accepted
Widening a numeric format (int32 → int64)Every recorded value is still in range
Removing a semantic format (Uuid → String)Every recorded value that satisfied the parser is still a perfectly good unconstrained value
Adding an enum value, or a disjoint oneOf/anyOf variantNothing recorded matches it
Relaxing additionalProperties or a boundStrictly more is accepted
Adding a workflow type, or publishing a schema for the first timeNothing was recorded under a contract that did not exist
Changing the branch container oneOf → anyOfoneOf required exactly one match; anyOf accepts at least one, so strictly more is accepted
Removing a workflow typeGated more accurately elsewhere — see below
Editing any annotation (title, description, examples, …)Not a constraint; produces zero deltas. default is not in this set — serde substitutes it, so it is compared where it is live

Why removing a workflow type is compatible here

Deleting a #[workflow] handler while executions are still in flight is a real hazard — but this gate is the wrong instrument for it. It cannot see how many non-terminal executions of that type exist. harvest workflow-types reachability (issue #520) answers that question exactly, by counting live runs. Double-gating would mean every legitimate handler removal needs a schema acknowledgement it can learn nothing from — pure acknowledgement fatigue. So this gate defers, and says so in the delta's reason.

Fail-closed on unanalysed keywords

The differ reasons about a fixed keyword vocabulary:

Code
type  required  properties  enum  items  additionalProperties
allOf  anyOf  oneOf  minLength  maxLength  minimum  maximum
minItems  maxItems  format  $ref

That is a deliberate superset of what the engine's own start-time validator (autumn_harvest::info::validate_against_schema) enforces — it covers minItems/maxItems and numeric format, which the validator ignores. Erring wide is the safe direction for a gate: at worst it asks for an acknowledgement on a change that would not actually have broken anything.

Any other constraint keyword (pattern, patternProperties, if/then, not, const, dependencies, …) is reported breaking on any change — added, modified, or removed.

Removing a constraint is not reliably a loosening. Dropping a patternProperties entry while additionalProperties: false remains tightens the schema: keys the pattern used to admit become "additional" and are now rejected. The differ does not model keyword interactions, so it refuses to guess and lets you acknowledge.

The same verdict applies through one level of indirection. An unanalysed keyword may point into the schema — "not": {"$ref": "#/$defs/T"} — and stay byte-identical while T is rewritten underneath it. Nothing in the analysed traversal walks there, because no analysed keyword references T, so without this the identical change would be breaking written inline and invisible written through a $ref. Every local reference reachable from an unchanged unanalysed constraint is therefore resolved on both sides (transitively, and cycle-safe), and any difference in a target is reported breaking. A target that resolves on only one side counts as a difference.

The same sweep covers one keyword that is nominally analysed. A node carrying both oneOf and anyOf is outside the branch model: only one of them is traversed as the branch set, so a $ref inside the other would be walked by neither the analysed traversal nor — since both keywords are in the analysed set — the sweep above. Both are enforced independently at runtime, so the ignored sibling's references are swept too. (A change to the ignored sibling's own value is already reported breaking outright; this covers the case where it is byte-identical and only its target moved.)

There is one deliberate exception to the fail-closed rule, and it is the only place an unanalysed keyword can change with no delta: a keyword sitting beside a $ref that resolves. The engine resolves $ref in a loop that reassigns schema = resolved and then validates only the landing node, so a sibling constrains nothing at runtime — editing it cannot reject a recorded payload, and reporting it would be a false BREAKING rather than a conservative one. The differ discards siblings the same way, which is what keeps the two in agreement. This is safe only while every keyword the engine enforces is one the differ analyses, so that invariant is pinned by a test that checks ten unanalysed draft-07 keywords are genuinely inert at runtime; teaching the engine one of them fails that test and forces this exception to be revisited. What the $ref points at is still compared, so the wrapper is not a blind spot.

Limits the gate does not cover

Four are worth knowing before you rely on it:

  • It cannot see behind an unresolvable $ref. A change to such a reference is reported breaking, but if both revisions point at the same external document and the document changes, that is outside this gate's reach. Keep published schemas self-contained — schemars output already is.

    Local reference cycles are resolvable and are compared. Resolution walks the chain until it repeats, and the node it stops on — which still carries the $ref that closes the loop — has its own keywords compared like any other: {"$ref": "#", "type": "string"} → {"$ref": "#", "type": "integer"} is breaking, matching the engine, which breaks the cycle the same way and then enforces that type. Recursive types stay silent; only a real change to a cycle's landing node is reported.

  • oneOf disjointness is proved pairwise, from the schema. An added branch is treated as disjoint only when every pair of branches in the set is provably disjoint — by disjoint type sets, by an internal tag pinning the same key to different values, by different singleton enums, or by serde's externally-tagged shape (one required property, one declared property, under additionalProperties: false). All three of those are exactly what schemars emits, so a derived enum keeps its proof.

    Carrying a discriminator each is not enough, which is why the proof is pairwise: an internal tag on tag beside one on kind are both real discriminators, yet {"tag":"A","kind":"B"} satisfies both branches — and oneOf requires exactly one match, so that recorded payload is rejected. For the same reason two open single-required-field objects are not disjoint: each treats the other's key as an undeclared extra. A set that is not provably disjoint reports an addition as breaking; a hand-written schema that invents its own oneOf shape falls into that arm, or into the indistinguishable-branches handling below.

    A type proof also requires every declared name to be one the engine actually enforces — array, boolean, integer, null, number, object, string. A name outside that set accepts every value rather than none, so it is disjoint from nothing, and a branch declaring one is never proved disjoint. schemars only ever emits the seven, so this can fire only on a hand-written schema.

    The same rule rejects const as a proof. const is not in the analysed set and the engine's validator has no arm for it, so a const-tagged branch accepts every value — the universal set, exactly like an unrecognised type name. Only a singleton enum proves anything, because enum is enforced. const still identifies a branch, though: matching a baseline branch to its current counterpart never claims disjointness, and ignoring the tag there would make two differently-tagged branches look alike and hide a variant removal. So a const tag keys the branch but mints no discriminator — identity and proof are separate jobs. schemars emits singleton enums, so this too fires only on a hand-written schema.

    The mirror-image hazard is covered too: widening an existing oneOf branch until it overlaps a sibling is breaking, because a payload that used to match one branch now matches two. Narrowing needs no such check — it cannot create an overlap, and the payloads it drops are already reported by the narrowing.

    That check runs whenever the branch changed, including when the change was made through a $ref some earlier path already walked. Resolution memoises each $ref pair so recursive types terminate and the walk stays linear, which means a second visit emits nothing — the deltas are already recorded under the first path. Reading that silence as "unchanged" would skip the overlap check for a definition shared between an ordinary property and a branch, so the memo also records whether each pair actually changed. A repeat visit to a changed pair is treated exactly like a diff truncated by the storage cap: the overlap is checked, with no local deltas to attribute it to.

    A self-recursive branch — one whose $ref points back at the definition currently being walked — has no verdict to read yet, since the traversal that would produce it is the one this check is nested inside. Guessing either way is wrong: unchanged hides a real rebind, and changed would report every unchanged recursive type as breaking (the overlap test asks whether the branch overlaps a rival as it now stands, not whether the overlap is new). Such a check is therefore parked and resolved when that definition finishes and its verdict is known — exact, because a branch that is a $ref to a definition changes precisely when the definition does.

  • Indistinguishable branches are compared by position. Two multi-field object variants of a #[serde(untagged)] enum both key as type:object, so the differ cannot match them by identity. Failing closed unconditionally would report an unchanged set breaking — and because the ambiguity is present on both sides, that workflow's own baseline could never pass the gate again, blocking every unrelated change to the repository. So when both sides have the same number of branches they are compared pairwise by position: unchanged yields nothing, and a nested change is still caught by the recursion. The accepted cost is that genuinely reordering such branches produces positional deltas that do not correspond to a single edit. They are acknowledgeable — and for anyOf a reorder is a real rebind risk anyway. A change in the branch count remains unclassifiable and still fails closed.

  • Declaring a key that recorded payloads already carried as an extra. Serde ignores an undeclared key but parses a declared one. So if some recorded payload carried b as an undeclared extra, adding b: Option<String> makes that payload fail to deserialize whenever its b was not a string.

    The gate catches this only when it is provable: when the baseline declared what an extra had to look like ("additionalProperties": {"type": "integer"}) and the new property does not accept every value that allowed, some recorded b is now invalid and the addition is reported breaking. On a fully open object (additionalProperties absent or true — what schemars emits by default) nothing is provable in either direction: the baseline permitted any value for b, so the differ cannot know whether a payload carrying one exists. A properties value that is present but not an object is the same case, not a separate one: the validator reads that keyword as "an object, or nothing", so "properties": "oops" declares exactly nothing — indistinguishable from omitting it. Failing closed on only the malformed spelling would make the verdict depend on a difference the runtime cannot observe. Reporting it breaking would make every optional-field addition breaking, which is both the single most common compatible change and the opposite of what this gate is for, so it stays compatible.

    The mitigation is #[serde(deny_unknown_fields)]. A baseline that denied unknown keys could not have recorded b at all, which turns the unprovable case into a provably safe one — such an addition is reported compatible on those grounds rather than by assumption.

Annotations never dirty the file

schemars copies your doc comments into description. Storing them would mean every doc edit produced a spurious diff, and the baseline would rot until everyone stopped reading it.

Stored schemas are therefore canonicalised: the pure annotations (title, description, $comment, $schema, $id, examples, deprecated, readOnly, writeOnly) are stripped, enum arrays are sorted, and the two schemars unit-enum forms are normalised to one. Both sides are canonicalised again at diff time, so a doc-comment edit yields both zero deltas and a byte-identical artifact.

That list is the whole of it. It is generated into the artifact's own description from the same constant the canonicaliser uses, so the file can never claim to strip something it compares — read compatibility.ignored_annotations for the authoritative set.

Two keywords that look like annotations are deliberately not in it:

format is not stripped. To a validator it is advisory, but the field's type is what runs the parser: Uuid accepts only what Uuid::from_str accepts. So introducing or changing a semantic format is breaking (a recorded value the new parser rejects no longer deserializes) and removing one is compatible.

default is not stripped either, even though it is a pure annotation to a validator — no value of it changes whether a payload validates. It is not an annotation to serde: #[serde(default = "retries")] leaves the field optional and records the computed fallback there, so changing the function from 3 to 5 means every recorded payload that omitted the key deserializes to a different value. Same JSON, different meaning, and invisible if it were stripped. A default is compared only where serde would actually substitute it — beside a key that is optional on both sides. One beside a required key, or on a root or array-item schema, is never substituted and stays silent, so documentation edits do not produce deltas.

The strip also applies to schema keywords only, never to a map keyed by names you chose: properties, patternProperties, definitions, $defs, dependencies, dependentSchemas, and dependentRequired. A field called description is a field, and deleting it would make it invisible — a change to it would canonicalize identically on both sides. What puts a keyword in that set is the shape of its keys; the values may be sub-schemas or, for dependentRequired and one form of dependencies, arrays of property names.


Exit-code contract

CodeMeaningstdout
0Every delta is compatible (including "no deltas at all")the diff
1At least one delta is breakingthe diff, then the error on stderr
1The check could not run: a baseline/current file is missing, unreadable, or malformed(nothing) — error on stderr
1The diff was truncated at the delta cap, so it cannot certify anythingthe partial diff, then the error

1 is deliberately overloaded: from CI's point of view "breaking change" and "the gate could not run" are the same answer — do not merge. Distinguish them by stderr, or by --format json (which prints a parseable diff only in the first two rows).

The diff is printed to stdout before the non-zero exit whenever one exists, so CI logs are self-explanatory. A read/parse failure has no diff to print.

Bounds are compared exactly, never through f64 with a tolerance: f64::EPSILON is machine epsilon at 1.0, so as an absolute tolerance it swallows a real narrowing like minimum: 1e-20 → 2e-20; and f64's 53-bit mantissa collapses distinct integers above 2^53, so a bound on an i64 field could move with no delta. Integers compare through i128. A mixed pair — one integer, one float, as when a bound is rewritten 9007199254740992.0 → 9007199254740993 — keeps the integer exact and compares it against the float's floor, with any fractional remainder breaking the tie; converting the integer to f64 would reintroduce exactly the collapse above. Only a float/float pair uses f64, where f64 is both values' own representation.

Why truncation fails

The differ caps how many deltas it will enumerate. Past that cap the report is incomplete, so exiting 0 would assert something the tool cannot know. It fails closed instead: exit 0 always means nothing breaking, never nothing breaking that fit.

Output formats

--format text (default) prints one line per delta:

Code
<label>.<role>: <field_path> — <verdict> — <reason>

followed by a summary line. The label is a workflow name, activity:<name> or <workflow>/side_effect:<id>. The role of a side effect is value. --format json emits the machine-readable diff:

Json
{
  "deltas": [
    {
      "workflow": "onboarding",
      "role": "input",
      "field_path": "/email_address",
      "change": "required_property_added",
      "verdict": "breaking",
      "reason": "required property `email_address` added; …"
    }
  ],
  "breaking_count": 1,
  "compatible_count": 0
}

truncated: true is added only when the delta cap was hit.

A delta about an activity or a side effect also has a subject key (activity or side_effect). For an activity, workflow holds the activity name. A side-effect delta also has a side_effect key that holds the id. A workflow delta has neither key, so its JSON is unchanged. A JSON consumer must read subject before it reads workflow as a workflow name.

field_path is an RFC 6901 JSON Pointer for ordinary object properties, matching the violation format POST /workflows/{name}/start already returns — so /email_address and /address/city can be fed straight to a pointer library.

It is a locator, not a guaranteed pointer, in four cases where the difference is not at a concrete instance path. These use deliberately pointer-invalid syntax so they can never be mistaken for a real key:

FormMeans
"" (empty)The schema as a whole — e.g. it was published or withdrawn
/[]The array's items schema (every element)
/[0], /[1], …A specific tuple position
/{*}The map's additionalProperties value schema
/<variant:Paid>An externally tagged oneOf/anyOf branch
/<tag:kind="paid">An internally tagged branch
/<unit:"Cancelled">A unit (fieldless) enum branch

Ordinary property names are escaped per RFC 6901 (/ → ~1, ~ → ~0), so a field literally named a/b renders as /a~1b.


Acknowledging a deliberate breaking change

Sometimes a breaking change is correct: you have drained the in-flight executions, pinned old runs to an old build with build-id routing (issue #171), or reset them (issue #148).

The gate must not block you — but the acknowledgement must never be silent.

Console
$ harvest schema update \
    --current /tmp/current.json \
    --acknowledge "GDPR rename; in-flight runs drained via reset (#148)" \
    --recorded-in docs/changelog.d/pr-794-schema-contract-gate.md
schema baseline updated: 2 delta(s) recorded, 2 acknowledged as breaking

This rewrites the baseline and appends an audit record per absorbed delta:

Json
"acknowledged_breaking_changes": [
  {
    "workflow": "onboarding",
    "role": "input",
    "field_path": "/email",
    "change": "property_removed",
    "reason": "GDPR rename; in-flight runs drained via reset (#148)",
    "recorded_in": "docs/changelog.d/pr-794-schema-contract-gate.md"
  }
]

Both the schema change and the justification land in the same reviewable git diff. A reviewer sees what changed, why it was safe, and where the migration is written up.

Guardrails:

  • update refuses a breaking delta without --acknowledge, and names the flag in the error.
  • A blank or whitespace-only --acknowledge is refused: a rubber stamp is not an acknowledgement.
  • On refusal the baseline is left untouched.
  • A truncated diff cannot be acknowledged. The artifact promises an audit record for every absorbed breaking delta, but the stored listing is capped while the rebase takes the whole contract — so the excess would be absorbed with nothing naming it. Split the change into smaller steps.
  • A purely compatible change needs no acknowledgement — just re-run update.
  • The audit log is append-only: prior acknowledgements are carried forward, so the record of past migrations is never quietly erased.

Recipes

GitHub Actions

Yaml
  schema-gate:
    name: Workflow schema contract
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - name: Generate the currently published schemas
        run: cargo run --quiet --bin dump-schema-contract > /tmp/current.json
      - name: Gate backward-incompatible payload changes
        run: |
          cargo run --quiet -p autumn-harvest-cli --bin harvest -- \
            schema check --require-current --current /tmp/current.json

schema check needs no database and no services, so it belongs in the same cheap lint job as det-check.

Why --require-current in CI

Without it the gate passes on a compatible delta and nobody regenerates the artifact — so the baseline records what was deployed some time ago while the gate reads it as what was deployed last. That gap round-trips:

  1. add an enum variant — compatible, so the gate passes and the artifact is left alone;
  2. deploy, and record payloads carrying the new variant;
  3. remove the variant again — the generated contract now equals the stale baseline, so the gate sees zero deltas.

Replay of anything written by the intermediate release fails, and no step ever reported a delta. --require-current refuses any unabsorbed delta, so step 1 cannot merge until the artifact is advanced — and step 3 is then a removal against a baseline that knows the variant existed, which is breaking and needs an acknowledgement.

The flag also checks the artifact's regenerated metadata, which the differ never looks at — it reads only workflows, activities and side_effects. compatibility is the machine-readable claim about which rules this gate implements, and description is the same claim in prose; a hand-edited analysed_keywords would otherwise sail through and tell a reviewer this build enforces something it does not. Both are compared, and a mismatch names which drifted.

Two siblings are deliberately not compared. version records which build produced the artifact rather than what the gate checks, so comparing it would force a regeneration on every crate version bump for no change in meaning. contract_version needs no comparison at all — parsing already hard-refuses a value this build does not implement, and rebuilds workflows, activities, side_effects and coverage from the entries.

The fix is always the same: run harvest schema update, and commit the regenerated artifact alongside the change.

The step above proves the artifact is current. On its own it is bypassable: regenerate the artifact by hand in the same PR and it agrees with the generated contract, so the diff is empty and a replay-breaking change merges with no justification recorded. schema update refuses to do that — but nothing stops a contributor overwriting the file directly.

The remaining signal is the artifact's change since its previous revision:

Yaml
      - name: Verify the escape hatch was used
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          ARTIFACT: docs/workflow-schema-contract.json
        run: |
          git fetch --no-tags --depth=1 origin "$BASE_SHA" || true
          if git cat-file -e "${BASE_SHA}:${ARTIFACT}" 2>/dev/null; then
            git show "${BASE_SHA}:${ARTIFACT}" > /tmp/base.json
            cargo run --quiet -p autumn-harvest-cli --bin harvest -- \
              schema check --baseline /tmp/base.json --current /tmp/current.json \
                           --acknowledged-in "$ARTIFACT"
          fi

With --acknowledged-in, a breaking delta is allowed only when the artifact records it — so the legitimate schema update --acknowledge path passes and a hand-edit fails. Requires the base commit to be fetchable (fetch-depth: 0, or the targeted fetch above); the check is skipped on the artifact's first introduction, when there is no previous revision to compare.

The same step enforces that the acknowledgement log is append-only. Coverage is matched as a multiset difference against the base revision's records, so a record recorded for an earlier break cannot be reused for a new one. Editing an existing record to point at a different break would defeat that — the retargeted entry looks like fresh coverage — so a record present at the base revision and missing now is reported on its own, with the remedy being to restore it and append. Editing only a record's reason or recorded_in is deliberately allowed: neither can make a record cover a different break.

Pre-commit hook

Shell
#!/bin/sh
# Block commits that break replay of in-flight executions.
cargo run -q --bin dump-schema-contract > /tmp/harvest-schema-current.json || exit 1
if ! cargo run -q -p autumn-harvest-cli --bin harvest -- \
        schema check --require-current --current /tmp/harvest-schema-current.json; then
    echo "A workflow payload schema changed, and the baseline is behind." >&2
    echo "Absorb a safe change with:" >&2
    echo "  harvest schema update --current …" >&2
    echo "…or acknowledge a deliberate breaking migration with:" >&2
    echo "  harvest schema update --current … --acknowledge \"<why this is safe>\"" >&2
    exit 1
fi

Regression tests

Three guards keep this artifact honest, all in autumn-harvest/tests/integration/workflow_schema_contract_tests.rs, which pulls the file in with include_str! so a hand-edit cannot slip past:

TestGuards
the_checked_in_baseline_parses_and_is_self_consistentIt parses, and contract_version matches SCHEMA_CONTRACT_VERSION
the_checked_in_baseline_workflows_are_sorted_and_uniqueStable ordering, no duplicate entries
the_checked_in_baseline_schemas_are_canonicalisedNo annotations were committed, so doc edits stay no-ops

Plus the CI step Gate workflow payload-schema changes (issue #794), which runs both halves of the recipe above: it regenerates the contract from the example registry and diffs it against the checked-in file, then compares that file against its revision at the PR base with --acknowledged-in — proving the artifact is current, that its own change was recorded, and that the documented recipe runs.

When you change a payload type

  1. Change the type.
  2. Regenerate --current (see above).
  3. Run harvest schema check. If it passes, run harvest schema update and commit the artifact with your change.
  4. If it fails and the change is deliberate, re-run update with --acknowledge "<why it is safe>" and --recorded-in <ref>.
  5. Commit the artifact in the same PR — the schema change and its justification belong in one reviewable diff.

Note. docs/workflow-schema-contract.json lives under docs/, and CI treats a docs-only PR as needing no code jobs — so the gate's own trusted input would be the one file able to change without being checked. CI's docs-only filter therefore carves this path out explicitly: a PR touching only the artifact still runs the matrix, so a hand-edited, stale, or malformed baseline is compared against the generated contract like any other change. The carve-out is pinned by the_schema_baseline_is_not_classified_as_a_docs_only_change. (The guide beside it, docs/workflow-schema-contract-guide.md, is ordinary prose and stays docs-only — the filter matches the artifact path exactly.)


Scope

In scope: the input_schema, output_schema, and error_schema published by registered #[workflow] types. Activity input and output schemas and side-effect value schemas.

Out of scope, deliberately:

  • Signal / query / update payload schemas. These are published — issue #610 shipped them on GET /workflows/registered/{name}/interface — but this gate does not diff them yet. Their payloads are recorded too (SignalReceived, UpdateAdmitted), so the same hazard applies; extending the artifact with those roles is the obvious follow-up.
  • Activity and side-effect schemas over HTTP. No route serves them. Use the generator binary.
  • Runtime enforcement / per-build pinning — issue #171.
  • Auto-migration or upcasting of recorded payloads.
  • Non-deterministic code — harvest det-check.

A workflow that publishes no schema is not gated at all — the check is opt-in, exactly like #373 itself. coverage in the artifact makes that visible at a glance, and withdrawing an already-published schema is breaking, so coverage cannot silently regress.


Where this sits in the release playbook

The schema gate is one layer of replay defence-in-depth, not the whole of it:

  1. harvest det-check — non-deterministic code reachable from workflow bodies.
  2. harvest schema check (this gate) — incompatible payload changes.
  3. WorkflowReplayer / harvest-replay — replay real exported histories against the new code. The in-flight flavour of this layer is the replay-drift gate: harvest history export-sample + WorkflowReplayer::replay_bundle, which samples the executions running right now rather than a curated fixture set. See replay-drift-gate.md.
  4. ctx.patched() / ctx.version() — fence a deliberate logic change across deploys.
  5. Build-id routing — pin in-flight executions to the old build during a breaking migration.
  6. harvest workflow-types reachability — confirm a handler is safe to delete.

Layers 1 and 2 are static and cheap enough for every PR. Layer 3 is the authoritative check, but it needs a recorded history to run against — which is exactly why the static gates exist.

Layers 2 and 3 are the pre-cutover pair, and they catch different halves of the same deploy hazard: this gate catches a payload shape change statically, without needing any history; the drift gate catches a control-flow change by actually replaying live in-flight work. Neither subsumes the other — run both.