Skip to content

Add IOA field invariants for cross-field validation#110

Merged
nerdsane merged 5 commits intonerdsane:mainfrom
ArunParthiban10:feat/ioa-field-invariants
Apr 14, 2026
Merged

Add IOA field invariants for cross-field validation#110
nerdsane merged 5 commits intonerdsane:mainfrom
ArunParthiban10:feat/ioa-field-invariants

Conversation

@ArunParthiban10
Copy link
Copy Markdown
Collaborator

@ArunParthiban10 ArunParthiban10 commented Apr 11, 2026

Summary

Introduces [[field_invariant]] in IOA spec grammar to declare single-entity
predicates ("when X matches, Y must also match"), and extends [[cross_invariant]]
to accept related(Target, fk).<Field> (in|not in) [...] for arbitrary field
names (not just status). Field invariants run on the OData write path via a
new pre_upsert_field_invariant_checks step inside run_write_prechecks,
alongside the existing relation and post-write checks.

Motivating use case: the upcoming Crucible reference app needs to reject
POST /tdata/Environments where ConfigType == "Local" sets cloud-only fields.
See docs/adrs/0041-ioa-field-invariants.md for the decision and rollout.

IOA TOML — what the new grammar looks like

Atomic leaves. Each leaf inspects exactly one field.

Predicate Passes when
{ field = X, absent = true } key X is missing from the payload, or its value is null
{ field = X, equals = V } key X exists and equals V (bool, string, number)
{ field = X, empty = true } key X is absent, null, "", or [] (shortcut for any_of [absent, equals "", equals []])

Combinators. { any_of = [...] }, { all_of = [...] }, { not = p }. Both when and require accept either a bare atomic predicate or a combinator.

Example 1 — simple equality rule

[[field_invariant]]
name = "LocalNetworkingMustBeUnrestricted"
when    = { field = "ConfigType", equals = "Local" }
require = { field = "NetworkingType", equals = "Unrestricted" }
message = "Local environments must use Unrestricted networking"

On POST/PATCH /tdata/Environments, if the post-write snapshot has
ConfigType == "Local" but NetworkingType != "Unrestricted", the write is
rejected with HTTP 409 and
error.details = { type: "field_invariant", invariant: "LocalNetworkingMustBeUnrestricted" }.

Example 2 — combinator for "absent or false"

[[field_invariant]]
name = "LocalCannotAllowMcpServers"
when    = { field = "ConfigType", equals = "Local" }
require = { any_of = [
  { field = "AllowMcpServers", absent = true },
  { field = "AllowMcpServers", equals = false },
]}
message = "Local environments cannot set allow_mcp_servers"

No dedicated absent_or_false leaf — composable atoms plus combinators cover
every case without accumulating compound predicates.

Example 3 — derived forms via not

# "field is present" = not absent
require = { not = { field = "Description", absent = true } }

# "field is not Archived" = not equals
require = { not = { field = "Status", equals = "Archived" } }

Example 4 — nested all_of / any_of

[[field_invariant]]
name = "CloudRequiresNetworkingConfig"
when = { field = "ConfigType", equals = "Cloud" }
require = { all_of = [
  { not = { field = "NetworkingType", absent = true } },
  { any_of = [
    { field = "NetworkingType", equals = "Unrestricted" },
    { field = "NetworkingType", equals = "Limited" },
  ]},
]}

Example 5 — extended [[cross_invariant]] grammar

[[invariant]]
name = "AllowedHostRequiresNonLocalParent"
kind = "hard"
on = "EnvironmentAllowedHost.*"
assert = 'related(Environment, EnvironmentId).ConfigType not in ["Local"]'

Reads as: on any action on EnvironmentAllowedHost, load the Environment
whose id equals the child's EnvironmentId FK; reject unless the parent's
ConfigType is anything other than "Local". The old
related(...).status in [...] form still parses unchanged — .status and
in are the backwards-compatible defaults.

Changes

  • Spec (temper-spec):
    • New FieldInvariant / FieldPredicate tree with leaves (absent,
      equals, empty) and combinators (any_of, all_of, not). Custom
      serde deserializer rejects mixed operators and absent=false/empty=false
      with precise errors.
    • New isolate_field_invariant_sections slicer — extracts only
      [[field_invariant]] blocks before handing to toml::from_str, so strict
      parsing on this section doesn't trip over unrelated quirks elsewhere in
      the IOA file (e.g. duplicate keys in [integration.config] that the
      hand-rolled parser tolerates).
    • Lint pass catches empty combinators, duplicate invariant names, unknown
      field names, and trivially-unsatisfiable equals-on-same-field.
    • [[cross_invariant]] parser extended to related(Target, fk).<Field> (in|not in) [...], carrying the field name through to the evaluator.
  • Server (temper-server):
    • SpecRegistry::field_invariants_for helper — clones the invariant list
      before releasing the lock, avoiding holds across awaits.
    • pre_upsert_field_invariant_checks in odata/constraints.rs, wired into
      run_write_prechecks between relation and post-write checks.
    • ConstraintViolationType::FieldInvariant + "field_invariant" mapping
      in constraint_violation_response.
    • Metrics: violations recorded via record_cross_invariant_violation(..., "field_invariant").
  • Tests: crates/temper-server/tests/field_invariants.rs — 6 end-to-end
    #[tokio::test] scenarios covering POST happy path, POST violation, POST
    inert rule, PATCH triggers violation, PATCH satisfies rule, and feature-flag
    bypass.
  • ADR: docs/adrs/0041-ioa-field-invariants.md.

Verification

  • cargo test -p temper-spec — 178 pass
  • cargo test -p temper-server --test field_invariants — 6 pass
  • cargo test --workspace — green (includes evolution app regression check
    that failed before the slicer fix)
  • DST Compliance Review: PASS (25-pattern scan clean — no HashMap, no new
    awaits under locks, no spawn/threads/RNG/fs/env/network)
  • Code Quality Review: PASS (one non-blocking warning: add a dedicated unit
    test for isolate_field_invariant_sections in a follow-up)

Test plan

  • cargo test -p temper-spec
  • cargo test -p temper-server
  • cargo test --workspace
  • DST compliance review
  • Code quality review

🤖 Generated with Claude Code

Introduces [[field_invariant]] in IOA spec grammar to declare single-entity
predicates ("when X matches, Y must also match") that run on the OData write
path alongside relation and cross-invariant checks. Extends [[cross_invariant]]
to accept related(Target, fk).<Field> (in|not in) [...] on arbitrary field
names, not just status. Motivating use case is the upcoming Crucible reference
app rejecting POST /tdata/Environments where ConfigType == "Local" sets
cloud-only fields; see docs/adrs/0041-ioa-field-invariants.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
rita-aga added a commit that referenced this pull request Apr 12, 2026
Preserves the field invariant and related-field cross-invariant work
from PR #110. Fixes:

- Replace production .unwrap() calls in field_invariant.rs (table.get
  paths), lint.rs and parser.rs (chars.next()) with explicit match/
  Option control flow to satisfy the Integrity & DST Patterns gate.
- Fix rustfmt drift across all changed files.
- Base branch corrected from main to staging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
rita-aga added a commit that referenced this pull request Apr 12, 2026
* fix: make field invariant CI pass

Preserves the field invariant and related-field cross-invariant work
from PR #110. Fixes:

- Replace production .unwrap() calls in field_invariant.rs (table.get
  paths), lint.rs and parser.rs (chars.next()) with explicit match/
  Option control flow to satisfy the Integrity & DST Patterns gate.
- Fix rustfmt drift across all changed files.
- Base branch corrected from main to staging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: split field invariant code for readability ratchet

Move field invariant check into field_constraints.rs, extract tests to
field_invariant_tests.rs, compact constraint constructors, and update
the readability baseline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
nerdsane and others added 4 commits April 13, 2026 21:57
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@nerdsane nerdsane merged commit 78aff1b into nerdsane:main Apr 14, 2026
5 checks passed
nerdsane added a commit to ArunParthiban10/temper that referenced this pull request Apr 14, 2026
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

2 participants