Normative design for the conformance suite enforcing TECH_SPEC §11.2:
Every declarative reactive rule that affects requiredness/visibility MUST be enforced in the compiled validator, so the client-side evaluation is purely cosmetic.
Everything here is implementable directly. The fixture corpus exists (docs/reactivity/fixtures/*.fixture.json, 27 files, 47 cases); a reference implementation of the algorithm exists (spikes/jsonlogic-reactivity/src/mirror.ts, 0-failure run).
1. The invariant, restated as assertions
For every reactive fixture and every case, in both create and edit modes (where the fixture declares no mode restriction):
- (a) Validator exclusion. A field hidden by the case state is absent from the compiled validator’s key set: submitting an invalid value for it produces no error entry for that key, and the key is absent from validated output.
- (b) Fill exclusion. A field hidden (or
disabledWhen-true) by the case state is absent from the fill set: submitting a value for it — bypassing the client UI entirely — leaves the model attribute untouched (create: attribute keeps its non-submitted default; edit: attribute keeps the record’s prior value). - (c) Requiredness enforcement.
requiredWhenevaluating true for the case state makes the key required server-side: omitting it fails validation with an error keyed to the field, even though a bypassing client never rendered the field. When the rule evaluates false, omitting it passes.
Plus (d) Parity. The client evaluator and the server evaluator produce identical visibility/requiredness maps for every case (the fixtures are the shared oracle; D2-3 consumes the same files).
2. Fixture format (v1)
Files: docs/reactivity/fixtures/<nn>-<pattern>.fixture.json — the canonical home, and it stayed there when B2-3 landed. Both conformance suites read these files directly by relative path (packages/core/tests/mirroring.spec.ts, packages/ui/tests/reactivity_fixtures.ts) rather than importing a copy: the corpus is the shared oracle for client/server parity, and a copy is a corpus that can drift from the thing both sides are measured against. The W1-10 copies under packages/core/tests/fixtures/reactivity/ are gone.
{
"version": 1, // fixture format version (required)
"name": "10-required-when-equals", // unique, kebab-case
"pattern": "required-when-equals", // catalogue row id (docs/reactivity/catalogue.md)
"classification": "jsonlogic", // jsonlogic | capability-url | registry-transform | server-closure
"note": "…", // optional prose
"fields": [ // flat field list (layout already flattened)
{
"key": "companyName", // state key; unique within the fixture
"type": "text", // §7.3 type key (drives resourceFromFixture mapping)
"default": "x", // optional; merged UNDER case state (R1)
"required": true, // optional static requiredness
"virtual": false, // optional; virtual fields never fill
"hiddenOn": ["edit"], // optional static projection (§7.4), tested by R2
"reactive": { // optional ReactiveSpec, wire shape (§9.3 + the reactivity contract)
"live": { "debounceMs": 300 },
"visibleWhen": { "===": [{ "var": "status" }, "published"] },
"requiredWhen": { /* JsonLogicRule */ },
"disabledWhen": { /* JsonLogicRule */ },
"sets": [{ "target": "slug", "transform": "slugify", "source": "title", "if": { "var": "on" } }],
"refetch": { "url": "urls.options", "withState": ["country"] },
"refetchSchema": true
},
"children": [ /* item sub-form of a state boundary; same field shape, recursive */ ]
}
],
"cases": [
{
"state": { "status": "draft" }, // full submitted state (pre-validation)
"mode": "create", // optional, default "create"
"expect": {
"visibility": { "publishedAt": false }, // keys to check (subset allowed)
"validator": { "keys": ["status"], // EXACT validator key set
"requires": [] }, // EXACT required key set
"fill": { "keys": ["status"], // EXACT fill key set
"with": { "slug": "hello-world" } }, // optional exact filled values
"derived": { "slug": "hello-world" }, // optional EXACT sets re-derivation map
"refetch": { "url": "urls.options", "withState": ["country"] }, // optional shape assertion
"nested": { // optional per-ROW expectations (R-7)
"items": [ // one entry per submitted row, in order
{ "keys": ["kind", "note"], // EXACT validator key set for the row
"requires": ["note"], // EXACT required key set for the row
"fill": ["kind", "note"], // EXACT fill key set for the row
"with": { "slug": "hello-world" } } // optional exact validated row values
]
}
}
}
]
}Exact-set expectations (keys, requires) are deliberate: subset assertions would let a leaking field pass. visibility is a subset map because fixtures may pin only the reactive fields.
3. Algorithm (what the server does at submit — and what the test drives)
Rules R1–R6 (implemented in spikes/jsonlogic-reactivity/src/mirror.ts, normative for B2-3):
- R1 — Evaluation state. Rules evaluate against the full submitted state with field defaults merged under it (submitted wins). Hidden fields’ submitted values ARE visible to rules. No fixpoint: one pass; rule outcomes never feed back into the state. This is deterministic and identical to the client, which evaluates against live
FormStateincluding hidden fields. - R2 — Static projection first. A field absent from the mode (§7.4:
hiddenOn,visibleOn,canSeecompile-time removal) is excluded from validator and fill set before any rule runs. - R3 — Hidden ⇒ excluded.
visibleWhenfalse ⇒ key absent from validator keys AND fill keys, even if present in the payload. - R4 — Requiredness mirrored.
requiredstatic orrequiredWhentrue (same evaluation state) ⇒ required.disabledWhentrue suppresses requiredness. - R5 — Disabled ⇒ not filled.
disabledWhentrue ⇒ excluded from fill set; a tampered submission is shape-validated but its value dropped. - R6 — Sets re-derived.
setsentries are recomputed server-side from submitted source values (guardifevaluated on the same state), and derived values overlay the fill for fillable targets.fn:*resolves from the server transform registry; unregistered ⇒ skip + dev warning (fail closed, nevereval).
Pseudo-code for the conformance driver (runMirroringFixture):
for fixture in corpus:
resource = resourceFromFixture(fixture) // §4
for case in fixture.cases:
for mode in (case.mode ? [case.mode] : ['create','edit']):
state = defaults(fixture.fields) <⊂ case.state // R1
// (a) validator exclusion
validator = compileValidator(resource, mode) // §11.1 compiler
probe = { ...case.state, <each hidden key>: INVALID_VALUE } // e.g. 12345 for a string field
outcome = validateWith(resource, mode, probe)
assert outcome.errors has no entry for any hidden key
assert outcome.output has no hidden key
assert validatorKeySet(validator, state) == case.expect.validator.keys // exact
// (b) fill exclusion
record = save(resource, mode, payloadWithHiddenValues, existingRecord?) // transaction, rolled back or in-memory model
for key in hiddenKeys(fixture, case, mode):
assert record[key] == priorValue(key) // default (create) / untouched (edit)
assert fillKeys(resource, mode, state) == case.expect.fill.keys // exact
for (k, v) in case.expect.fill.with: assert record[k] == v // R6 overlay
// (c) requiredness enforcement
assert requiredKeys(resource, mode, state) == case.expect.validator.requires
for key in case.expect.validator.requires:
outcome = validateWith(resource, mode, { ...case.state, [key]: MISSING })
assert outcome.errors has key
// (d) parity — client side, same fixture file (D2-3)
visibilityMap = clientEngine.evaluate(fixture.descriptorForm, case.state)
assert visibilityMap == serverVisibilityMap(resource, mode, state)Mode expansion: fixtures without a mode on a case run in both modes unless a field’s hiddenOn restricts it (then the restricted mode expects the R2 outcome, as in fixture s2-*).
4. Test infrastructure to build
resourceFromFixture(fixture). Builds a throwaway BaseResource subclass from declarative field specs. Type mapping: fixture type → field factory (text → F.text, select → F.select, toggle → F.toggle, number → F.number, date/datetime/time → the temporal factories, …). Types whose tranche has not landed map onto the factory carrying the same §11.1 Vine base: belongs-to → F.number (until B2-4 adds the exists rule), repeater → an array-valued stand-in (until B2-6). Choice fields declare no options in the format, so the harness derives the enum members from the values the fixture itself uses. reactive JSON is replayed through the AUTHORING DSL — visibleWhen(rule), .sets(target, transform, { source, if }), .live(ms), dependentOptions(fn, dependsOn), and a closure for refetchSchema: true — so DSL → JSON compilation is itself covered (B2-3 removed the setReactiveSpec escape hatch W1-10 used). hiddenOn names form MODES, which the Field DSL does not project on directly; the harness expresses it as canSee((ctx, record) => …), since record presence is what distinguishes edit from create at compile time.
Assertion helpers (Japa plugin additions per §21, in @adonia/core/testing):
| Helper | Asserts | Invariant |
|---|---|---|
assertValidatorOmits(resource, mode, state, keys) |
invalid values for keys produce no error/output entries |
(a) |
assertValidatorKeys(resource, mode, state, keys) |
exact validator key set for state | (a) |
assertFillOmits(resource, mode, payload, keys) |
submitted values for keys never reach the model |
(b) |
assertRequiredWhen(resource, mode, state, key) |
omitting key fails with keyed error; including passes |
(c) |
runMirroringFixture(fixture) |
drives the whole §3 loop | (a)+(b)+(c) |
@adonia/ui gets one helper: evaluateFixtureCase(fixture, case) returning the client visibility/requiredness map, compared against expect (d).
Client/server evaluator parity. Both sides implement the closed-set reference evaluator (spikes/jsonlogic-reactivity/src/evaluator.ts is the normative semantics: strict structural ===, null-safe relational operators, JS truthiness, and/or value semantics with Boolean() at consumption, ./ item-relative paths). The fixture corpus is the conformance oracle — any semantic drift fails parity cases. Do NOT adopt json-logic-js on either side without re-running the corpus; its loose == is excluded by design.
5. Test matrix
Dimensions: pattern (27 fixtures) × case state (47 cases, rule true/false/boundary incl. null operand) × mode (create/edit) × side (server validator, server fill, client eval).
| Row | Coverage | Fixtures | Owner |
|---|---|---|---|
| R-1 | visibility conditions (equals, notEquals, in, notIn, truthy, and, or, not, threshold + boundary + null) | 01–09 |
B2-3 |
| R-2 | requiredness mirroring, UI bypassed — incl. a condition field supplied only by a DEFAULT and one that is itself hidden (both decide on the R1 state, never on the pruned body) | 10, 11, r4-required-when-defaulted-condition, r4-required-when-hidden-condition |
B2-3 (W1-10 via s3) |
| R-3 | disabled-when fill exclusion, tampered payload | 12 |
B2-3 |
| R-4 | sets re-derivation (slugify, uppercase, copy+if, tampered target) | 13–15 |
B2-3 |
| R-5 | capability-URL shape (refetch.withState; options endpoint contract tested separately) | 16, 17 |
B2-3/B2-4 |
| R-6 | registry transforms (fn:* both-sides, fail-closed unregistered) | 18, 19 |
B2-3, D2-3 |
| R-7 | repeater-row scoping | r7-repeater-row-scope (child default arming a ./ sibling rule, chained ./ visibility through a pruned child, absolute var reading the parent form), r7-repeater-row-sets (per-row sets re-derivation) + packages/core/tests/fields/repeater.spec.ts for the error-bag paths (items.2.title, items.0.children.1.name). Fixture fields carry children and cases carry expect.nested, so per-row decisions are corpus-observable rather than spec-suite-only |
B2-6 |
| R-8 | static projection interplay (§7.4 × §11.2) | s2 |
W1-10/B2-3 |
| R-9 | closure fallback (refetchSchema: true fields stay validated+fillable; dev warning emitted) |
s1 |
B2-3 |
| R-10 | W1-10 smoke: one-field (F.text) slice wired as required CI check | s3 |
W1-10 |
Server-closure fixture 20 carries cases: []: it asserts (by absence of reactive keys) that cross-field validation stays out of the wire grammar; its submit-time enforcement is covered by the §11.1 mapping suite (S0-6), not this one.
6. CI wiring
- W1-10 (superseded):
runMirroringFixtureovers3-w1-10-smoke(+ copied01,10). - B2-3 (current): the full corpus, discovered from
docs/reactivity/fixtures/, both modes, one Japa test per case × mode row, as a required check. New catalogue patterns MUST add a fixture in the same PR (conformance-as-CI, plan §5) — the suite enrols it automatically, andwhen_dsl.spec.tsfails until the pattern has a DSL spelling that emits the fixture JSON byte for byte. - D2-3: same corpus through the client engine (row (d)); the shared corpus is what lets B2-3 and D2-3 proceed in parallel.