tests and spec evolution

How a spec proves itself, and how it changes without losing live state.


tests

Embedded test cases. Each test sets a given state and asserts expected derived values or meta values. Tests are executed by ModelRuntime during spec validation and can be run via the mvn test build.

{
  "description": "high rating with no issues — high score, no priority flag",
  "given": {
    "$.overallRating":    5,
    "$.wouldRecommend":   true,
    "$.issueEncountered": false
  },
  "expect": {
    "$.sentimentScore": 100,
    "$.priorityFlag":   false
  }
}
Field Required Description
description no Human-readable label for the test case
given yes Map of $.path → value mutations to apply before asserting
expect yes Map of $.path → expected value; values can be concrete or meta assertion objects

Meta assertions in expect

An expected value may be a {"$meta": {...}} object to assert meta cache values rather than base/derived field values:

"expect": {
  "$.issueCategory": { "$meta": { "relevant": false } },
  "$.contactEmail":  { "$meta": { "relevant": true,  "readOnly": true } }
}

Spec Evolution (POST /models/{id}/spec/evolve)

A SpecEvolution document applies incremental changes to an existing spec. Each section supports independent upsert* and remove* operations. The evolved spec is validated and recompiled; if validation fails the request is rejected with 422.

{
  "newVersion":            "2.0.0",
  "newSchema":             { ... },
  "newViewDefinition":     { ... },
  "upsertDerivations":     [ { "path": "$.vat", "expr": "total * 0.2" } ],
  "removeDerivations":     ["$.oldField"],
  "upsertConstraints":     [ { "id": "c1", "expr": "qty > 0", "message": "...", "policy": "flag" } ],
  "removeConstraints":     ["oldConstraint"],
  "upsertEffects":         [ ... ],
  "removeEffects":         ["oldEffect"],
  "upsertMetaDerivations": [ ... ],
  "removeMetaDerivations": ["$.field#relevant"],
  "upsertDefaultValues":   [ { "path": "$.items[*]", "expr": "{ \"qty\": 1 }" } ],
  "removeDefaultValues":   ["$.customer"],
  "newConstants":          { "vatRate": 0.25 },
  "newTests":              [ { "description": "vat", "given": { "$.total": 100 }, "expect": { "$.vat": 20 } } ],
  "backfill":              { "$.shipping": 0 }
}
Field Description
newVersion Replace the version string
newSchema Replace the entire JSON Schema
newViewDefinition Replace the entire view definition; if absent, existing view is preserved
upsertDerivations Add or replace derivations matched by path
removeDerivations Remove derivations by path
upsertConstraints Add or replace constraints matched by id
removeConstraints Remove constraints by id
upsertEffects Add or replace effects matched by id
removeEffects Remove effects by id
upsertMetaDerivations Add or replace meta derivations matched by path#property key
removeMetaDerivations Remove meta derivations by path#property key
upsertDefaultValues Add or replace default-value rules matched by path
removeDefaultValues Remove default-value rules by path
newConstants Replace the entire constants map (omit to keep existing)
newTests Replace the entire embedded tests list wholesale (omit to keep existing; [] clears them)
backfill $.path → value map applied to the existing instance for new fields it lacks
expectedVersion Optimistic-concurrency precondition — apply only if the live version still equals this; otherwise 409

All fields are optional. An evolution with only {"newVersion": "2.0.0"} updates only the version and leaves all other sections unchanged.

Targeted schema, view, and constants diffs

newSchema, newViewDefinition, and newConstants each replace a whole section — safe for restructuring, but at scale (a large schema, a multi-view UI) resending the entire section is error-prone (an LLM may silently drop siblings) and wasteful. Each of those three sections also supports targeted diffs. Within one evolution the wholesale field and its diff fields are mutually exclusive (newSchema XOR the schema diffs, etc.); different sections may combine.

Schema tiers

Field Description
upsertSchemaDefs Map of $defs name → definition schema; replaces (or adds) that definition wholesale. One edit fans out to every $ref usage.
removeSchemaDefs Drop $defs entries by name. Rejected if the definition is still referenced (the referencing locations are listed).
upsertSchemaNodes [{ path, schema, required? }] — replace the node at a canonical data path (e.g. $.order.items[*].qty) wholesale; create intermediate containers when the path is new. required (tri-state) adds/removes the field from its parent’s required list. A path may not traverse a $ref — edit the shared definition instead.
removeSchemaNodes Drop nodes by canonical data path (also removes the parent required entry).

View tiers (see view-system.md)

Field Description
newDefaultView Set viewDefinition.defaultView.
upsertViews / removeViews Add/replace/remove a whole view by its id.
upsertComponents [{ viewId, component, parentId?, beforeId? }] — replace a component in place (by id), or place/move it: parentId (absent = view root) and beforeId (absent = append).
removeComponents [{ viewId, componentId }] — remove a component and its subtree.

Constants tiers

Field Description
upsertConstants Map of name → value; replaces (or adds) each named constant wholesale.
removeConstants Drop constants by name. Rejected if a $const.<name> reference is found in any expression (textual scan; dynamic $const[...] access is not detected).

Note the near-homophone pair: upsertConstants changes named values; upsertConstraints changes boolean invariants.

JSON Schema $defs/$ref. The engine resolves local definition references of the form {"$ref": "#/$defs/<Name>"} (lazily, so recursive types work). Non-local ref forms (external URIs, #/definitions/…, #/properties/…) and dangling refs are rejected at validation; a definition no $ref uses produces a warning. Before this support, fields behind a $ref silently skipped validation — such specs are now rejected loudly. See the schema section.

Data migration with backfill. When an evolution adds a (possibly required) base field, existing instances would otherwise have no value for it — leaving derivations that read it computing against null. The optional backfill map seeds those fields: for each $.path, if the instance does not already have a value there, the value is written before re-derivation (existing values are never overwritten). Backfill targets must be base (writable) fields and satisfy the new schema. After an evolution, all EAGER derivations are recomputed, so derivations added by the evolution are populated immediately without requiring a subsequent mutation.

State-compatibility guard. When an evolution changes the schema, the carried-forward (post-backfill) state is validated against the new schema. If a change would strand existing values (e.g. retyping number → string while the document still holds numbers), the evolution is rejected with 422 listing the incompatible paths — rather than committing state that fails its own schema. backfill only fills absent fields, so it cannot convert an existing value; that is out of scope (a migrate transform is a planned follow-up).